alishahryar1--free-claude-code
4f56a6aa17
## 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 -->
111 行
3.6 KiB
Python
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
|