项目文件夹

文件
Ali Khokhar 4f56a6aa17 Add Fable as a first-class Claude routing tier (#1099)
## Problem

Claude Code now sends `claude-fable-5` for the Fable alias, but FCC
treated it as an unrecognized model and collapsed it into the global
fallback route. Users could not map Fable traffic or reasoning behavior
independently. Fixes #1097.

## Changes

| Before | After |
| --- | --- |
| Fable requests inherited `MODEL` and `ENABLE_MODEL_THINKING`. | Fable
requests use `MODEL_FABLE` and `ENABLE_FABLE_THINKING` when configured,
otherwise inherit the existing defaults. |
| `/v1/models` omitted Claude Fable 5. | `/v1/models` advertises the
canonical `claude-fable-5` identifier. |
| Admin, documentation, validation, and smoke contracts described three
Claude tiers. | Admin, documentation, validation, and smoke contracts
describe Fable alongside Opus, Sonnet, and Haiku. |
| The package version was `4.3.1`. | The package version is `4.4.0`. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR adds Fable as a Claude routing tier. The main changes are:

- `MODEL_FABLE` and `ENABLE_FABLE_THINKING` settings.
- Fable routing and thinking resolution in `ModelRouter`.
- `claude-fable-5` in the model catalog.
- Admin, docs, smoke, and test coverage updates.
- Package version bump to `4.4.0`.
</details>

<h3>Confidence Score: 4/5</h3>

The changed routing path needs a fix for direct provider model ids
containing `fable`.

Fable settings, validation, admin fields, and model listing are
consistent with the existing tier patterns. Blank Fable settings inherit
the existing defaults. Direct provider model ids can receive the Fable
thinking override when their model name contains `fable`.

src/free_claude_code/application/routing.py

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Reproduced the Fable thinking overmatch by running a focused Python
repro that disables global thinking and enables Fable thinking, then
resolves sambanova/my-fable-ensemble-v2.
- The repro confirmed direct routing preserved provider\_id=sambanova,
provider\_model=my-fable-ensemble-v2, and
provider\_model\_ref=sambanova/my-fable-ensemble-v2, with
resolved\_thinking\_enabled and thinking\_enabled both true.
- Ran the fable-tier validation pytest, which finished with exit code 0
and 177 tests passed.
- Ran the runtime probe to exercise the API model list, Settings env
parsing, and ModelRouter paths, and observed a 200 OK on GET /v1/models,
with the catalog item id claude-fable-5 and the expected Fable vs
global-default routing behavior.
- Generated the probe source file used to exercise the API and Settings
paths, enabling repeatable validation without real provider credentials.

<a
href="https://app.greptile.com/trex/runs/14275379/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/application/routing.py | Adds Fable model and
thinking branches; the thinking branch can also match unrelated direct
provider model ids containing `fable`. |
| src/free_claude_code/config/settings.py | Adds optional Fable model
and thinking settings with blank-env inheritance and provider/model
validation. |
| src/free_claude_code/config/model_refs.py | Includes Fable in
configured chat model reference collection and dedupe. |
| src/free_claude_code/config/admin/manifest.py | Adds Fable model and
thinking controls to the admin manifest. |
| src/free_claude_code/api/model_catalog.py | Adds `claude-fable-5` to
the advertised Claude model aliases. |
| pyproject.toml | Bumps the package version to `4.4.0`. |
| uv.lock | Updates the editable package version to match
`pyproject.toml`. |

</details>

<details open><summary><h3>Flowchart</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Incoming model name] --> B{Direct provider or gateway id?}
  B -- yes --> C[Use provider/model directly]
  C --> D[Resolve thinking from provider model string]
  B -- no --> E{Claude tier match}
  E -- Fable --> F[MODEL_FABLE or MODEL]
  E -- Opus/Sonnet/Haiku --> G[Tier override or MODEL]
  E -- None --> H[MODEL]
  D --> I[Provider request]
  F --> I
  G --> I
  H --> I
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  A[Incoming model name] --> B{Direct provider or gateway id?}
  B -- yes --> C[Use provider/model directly]
  C --> D[Resolve thinking from provider model string]
  B -- no --> E{Claude tier match}
  E -- Fable --> F[MODEL_FABLE or MODEL]
  E -- Opus/Sonnet/Haiku --> G[Tier override or MODEL]
  E -- None --> H[MODEL]
  D --> I[Provider request]
  F --> I
  G --> I
  H --> I
```

</a>
</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Fadd-fable-routing-tier%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Fadd-fable-routing-tier%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fapplication%2Frouting.py%3A134-135%0A**Fable%20Thinking%20Overmatches%20Models**%0A%0AWhen%20a%20direct%20provider%20request%20uses%20a%20model%20id%20like%20%60sambanova%2Fmy-fable-ensemble-v2%60%2C%20the%20direct%20route%20bypasses%20tier%20remapping%20but%20still%20calls%20%60_resolve_thinking%28%29%60%20with%20the%20provider%20model%20string.%20With%20%60ENABLE_FABLE_THINKING%60%20set%2C%20this%20substring%20check%20applies%20Fable%20thinking%20behavior%20to%20an%20unrelated%20provider%20model%2C%20changing%20the%20outgoing%20request%20shape%20just%20because%20the%20model%20id%20contains%20%60fable%60.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1099&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (1): Last reviewed commit: ["Add Fable as a first-class
routing
tier"](https://github.com/alishahryar1/free-claude-code/commit/0705840c65511fd85c74fd2c62ba8ea97afe7c12)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43892323)</sub>

> Greptile also left **1 inline comment** on this PR.

<!-- /greptile_comment -->
2026-07-13 11:56:34 -07:00

111 行
3.6 KiB
Python

import time
import httpx
import pytest
from free_claude_code.core.anthropic.stream_contracts import (
assert_anthropic_stream_contract,
text_content,
thinking_content,
)
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.e2e import ProviderMatrixDriver
from smoke.lib.http import collect_message_stream, message_payload
from smoke.lib.server import start_server
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
skip_if_upstream_unavailable_exception,
)
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")]
def test_model_mapping_configuration_is_consistent(smoke_config: SmokeConfig) -> None:
models = smoke_config.provider_models()
if not models:
pytest.skip("no configured provider models with usable credentials/base URLs")
for provider_model in models:
assert "/" in provider_model.full_model
assert provider_model.model_name
def test_mixed_provider_model_mapping_when_configured(
smoke_config: SmokeConfig,
) -> None:
models = smoke_config.provider_models()
providers = {provider_model.provider for provider_model in models}
if len(providers) < 2:
pytest.skip("configure MODEL_* with at least two provider prefixes")
sources = {provider_model.source for provider_model in models}
assert sources <= {
"MODEL",
"MODEL_FABLE",
"MODEL_OPUS",
"MODEL_SONNET",
"MODEL_HAIKU",
}
assert len(providers) >= 2
def test_configured_provider_models_stream_successfully(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
try:
with start_server(
smoke_config,
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
name=f"provider-{provider_model.provider}",
) as server:
events = collect_message_stream(
server,
message_payload(smoke_config.prompt, model="fcc-smoke-default"),
smoke_config,
)
skip_if_upstream_unavailable_events(events)
assert_anthropic_stream_contract(events)
has_text = bool(text_content(events).strip())
has_thinking = bool(thinking_content(events).strip())
assert has_text or has_thinking, (
"provider returned no visible text or thinking content"
)
except Exception as exc:
skip_if_upstream_unavailable_exception(exc)
raise AssertionError(
f"{provider_model.source}={provider_model.full_model}: "
f"{type(exc).__name__}: {exc}"
) from exc
@pytest.mark.smoke_target("rate_limit")
def test_client_disconnect_mid_stream_does_not_crash_server(
smoke_config: SmokeConfig,
) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with start_server(
smoke_config,
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
name="disconnect",
) as server:
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=auth_headers(),
json=message_payload(smoke_config.prompt, model="fcc-smoke-default"),
timeout=smoke_config.timeout_s,
) as response:
assert response.status_code == 200, response.read()
for _line in response.iter_lines():
break
time.sleep(0.5)
health = httpx.get(f"{server.base_url}/health", timeout=5)
assert health.status_code == 200