alishahryar1--free-claude-code
6455c63e1d
## Problem FCC reduced reasoning to global and route booleans, mixing client intent, configuration, provider wire capabilities, output visibility, and history replay. That discarded named client efforts, encouraged model-name checks, and made provider behavior inconsistent. ## Changes | Before | After | | --- | --- | | Admin exposed global and route thinking toggles. | Admin exposes **Off**, **From client**, **Low**, **Medium**, **High**, **X-High**, and **Max**; Fable, Opus, Sonnet, and Haiku also expose **Inherit**. | | Request intent was repeatedly reduced to a boolean across routing and providers. | The application boundary resolves one immutable `ReasoningPolicy` with independent control, named effort, and exact positive token budget. | | Provider adapters could infer reasoning behavior from upstream model names or versions. | Provider profiles translate only documented provider-wide wire capabilities; architecture and contributor rules prohibit model-specific reasoning branches. | | Gateway reasoning controls were ad hoc. | [OpenRouter](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) and [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/models-and-providers) use documented reasoning objects, including exact budgets where representable. | | Named effort forwarding was inconsistent or absent. | [Gemini](https://ai.google.dev/gemini-api/docs/openai), [Ollama](https://docs.ollama.com/api/openai-compatibility), [LM Studio](https://lmstudio.ai/changelog/lmstudio-v0.4.8), [Fireworks](https://docs.fireworks.ai/guides/querying-text-models/reasoning), [Cohere](https://docs.cohere.com/docs/compatibility-api), [Wafer](https://docs.wafer.ai/serverless/api-reference), [Groq](https://console.groq.com/docs/reasoning), [Cerebras](https://inference-docs.cerebras.ai/capabilities/reasoning), [SambaNova](https://docs.sambanova.ai/docs/api-reference/chat-completions/create-chat-based-completion), and [Mistral](https://docs.mistral.ai/studio-api/conversations/reasoning) receive their documented named vocabularies with explicit provider-owned downgrades. | | Boolean thinking controls were mixed into shared conversion. | [DeepSeek](https://api-docs.deepseek.com/guides/thinking_mode/), [Kimi](https://platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model), [Z.ai](https://docs.z.ai/guides/capabilities/thinking-mode), [Cloudflare Workers AI](https://developers.cloudflare.com/changelog/post/2026-04-20-kimi-k2-6-workers-ai/), and [NVIDIA NIM](https://docs.nvidia.com/nim/large-language-models/1.15.0/thinking-budget-control.html) use provider-owned thinking-object or chat-template controls. | | Effort names and output limits could become fabricated reasoning budgets. | Exact budgets remain exact and are forwarded only through documented fields for OpenRouter, Fireworks, LM Studio, NIM, and [llama.cpp](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md); named efforts and output limits are never converted into token budgets. | | New-turn reasoning and prior-turn replay shared one switch. | Every profile independently declares native reasoning replay, `<think>` tag replay, provider-specific replay, or no replay; **Off** suppresses new reasoning output without corrupting required history. | | Providers without a stable generic compute control received guessed controls. | [MiniMax](https://platform.minimax.io/docs/api-reference/text-openai-api) requests split output only, while [GitHub Models](https://docs.github.com/en/rest/models/inference), [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/tasks/chat-completion), Codestral, and OpenCode keep provider defaults and use only their explicit replay profile. | | OpenAI Responses effort became a lossy Anthropic thinking boolean. | Responses preserves `reasoning.effort` through `output_config`, then resolves it through the same application policy as Messages without inventing a budget. | | Legacy booleans remained the persisted contract. | FCC-owned dotenv files migrate to typed `REASONING_*` values, explicit env files receive an actionable warning, documentation describes the ownership boundary, and the package advances to 4.8.0. | | Reasoning behavior was covered by scattered boolean assertions. | New policy, routing, encoder, provider, Admin, migration, Responses, and smoke contracts pass all five local CI checks: 2,368 tests passed, 40 skipped; 92 smoke tests collect and both live config migration checks pass. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR makes reasoning policy client-aware and independent of provider model names. The main changes are: - Adds one immutable reasoning policy resolved at the application boundary. - Adds typed root and route reasoning settings with Admin UI support. - Moves wire controls and history replay behavior into provider profiles. - Migrates owned dotenv files from legacy thinking booleans. - Expands provider, routing, migration, API, and smoke coverage. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues found in the changed code. <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** - Ran the contract-validation test suite with the specified test modules, and the tests reported 78 passed in 1.53s with exit code 0. - Reviewed the complete captured output artifact reasoning-contract-02-after.log to verify the final test outcomes and successful contract validation. <a href="https://app.greptile.com/trex/runs/14792858/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/config/env_migrations.py | Migrates legacy reasoning booleans in owned dotenv files and warns for explicit environment files. | | src/free_claude_code/application/reasoning.py | Resolves client controls and configured preferences into one provider-neutral reasoning policy. | | src/free_claude_code/application/routing.py | Carries route-level reasoning preferences into request-scoped policy resolution. | | src/free_claude_code/providers/openai_chat/reasoning.py | Provides shared provider encoders for reasoning controls and replay behavior. | </details> <sub>Reviews (2): Last reviewed commit: ["chore: release reasoning controls as 4.8..."](https://github.com/alishahryar1/free-claude-code/commit/9d4be767f7dbdca5709474012f43dcdc6f4347e3) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=44984039)</sub> <!-- /greptile_comment -->
273 行
9.5 KiB
Python
273 行
9.5 KiB
Python
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from free_claude_code.application.errors import UnknownProviderError
|
|
from free_claude_code.application.routing import ModelRouter
|
|
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
|
|
from free_claude_code.config.reasoning import ReasoningPreference
|
|
from free_claude_code.config.settings import Settings
|
|
from free_claude_code.core.anthropic.models import (
|
|
Message,
|
|
MessagesRequest,
|
|
TokenCountRequest,
|
|
)
|
|
from free_claude_code.core.reasoning import ReasoningControl, ReasoningEffort
|
|
|
|
|
|
@pytest.fixture
|
|
def settings():
|
|
settings = Settings()
|
|
settings.model = "nvidia_nim/fallback-model"
|
|
settings.model_fable = None
|
|
settings.model_opus = None
|
|
settings.model_sonnet = None
|
|
settings.model_haiku = None
|
|
settings.reasoning_policy = ReasoningPreference.CLIENT
|
|
settings.reasoning_fable = ReasoningPreference.INHERIT
|
|
settings.reasoning_opus = ReasoningPreference.INHERIT
|
|
settings.reasoning_sonnet = ReasoningPreference.INHERIT
|
|
settings.reasoning_haiku = ReasoningPreference.INHERIT
|
|
return settings
|
|
|
|
|
|
def test_model_router_resolves_default_model(settings):
|
|
resolved = ModelRouter(settings).resolve("claude-3-opus")
|
|
|
|
assert resolved.original_model == "claude-3-opus"
|
|
assert resolved.provider_id == "nvidia_nim"
|
|
assert resolved.provider_model == "fallback-model"
|
|
assert resolved.provider_model_ref == "nvidia_nim/fallback-model"
|
|
assert resolved.reasoning_preference is ReasoningPreference.CLIENT
|
|
|
|
|
|
def test_model_router_applies_opus_override(settings):
|
|
settings.model_opus = "open_router/deepseek/deepseek-r1"
|
|
|
|
request = MessagesRequest(
|
|
model="claude-opus-4-20250514",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
routed = ModelRouter(settings).resolve_messages_request(request)
|
|
|
|
assert routed.request.model == "deepseek/deepseek-r1"
|
|
assert routed.resolved.provider_model_ref == "open_router/deepseek/deepseek-r1"
|
|
assert routed.resolved.original_model == "claude-opus-4-20250514"
|
|
assert routed.reasoning.control is ReasoningControl.DEFAULT
|
|
assert request.model == "claude-opus-4-20250514"
|
|
|
|
|
|
def test_model_router_applies_fable_override(settings):
|
|
settings.model_fable = "open_router/anthropic/claude-fable-5"
|
|
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="claude-fable-5",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "anthropic/claude-fable-5"
|
|
assert routed.resolved.provider_model_ref == "open_router/anthropic/claude-fable-5"
|
|
assert routed.resolved.original_model == "claude-fable-5"
|
|
|
|
|
|
def test_model_router_resolves_route_reasoning_preferences(settings):
|
|
settings.reasoning_policy = ReasoningPreference.OFF
|
|
settings.reasoning_fable = ReasoningPreference.HIGH
|
|
settings.reasoning_opus = ReasoningPreference.MAX
|
|
settings.reasoning_haiku = ReasoningPreference.OFF
|
|
|
|
router = ModelRouter(settings)
|
|
|
|
assert (
|
|
router.resolve("claude-fable-5").reasoning_preference
|
|
is ReasoningPreference.HIGH
|
|
)
|
|
assert (
|
|
router.resolve("claude-opus-4-20250514").reasoning_preference
|
|
is ReasoningPreference.MAX
|
|
)
|
|
assert (
|
|
router.resolve("claude-sonnet-4-20250514").reasoning_preference
|
|
is ReasoningPreference.OFF
|
|
)
|
|
assert (
|
|
router.resolve("claude-3-haiku-20240307").reasoning_preference
|
|
is ReasoningPreference.OFF
|
|
)
|
|
assert router.resolve("claude-2.1").reasoning_preference is ReasoningPreference.OFF
|
|
|
|
|
|
def test_model_router_applies_haiku_override(settings):
|
|
settings.model_haiku = "lmstudio/qwen2.5-7b"
|
|
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="claude-3-haiku-20240307",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "qwen2.5-7b"
|
|
assert routed.resolved.provider_model_ref == "lmstudio/qwen2.5-7b"
|
|
|
|
|
|
def test_model_router_applies_sonnet_override(settings):
|
|
settings.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct"
|
|
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="claude-sonnet-4-20250514",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "meta/llama-3.3-70b-instruct"
|
|
assert (
|
|
routed.resolved.provider_model_ref == "nvidia_nim/meta/llama-3.3-70b-instruct"
|
|
)
|
|
|
|
|
|
def test_model_router_routes_prefixed_provider_model_directly(settings):
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="deepseek/deepseek-chat",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "deepseek-chat"
|
|
assert routed.resolved.original_model == "deepseek/deepseek-chat"
|
|
assert routed.resolved.provider_id == "deepseek"
|
|
assert routed.resolved.provider_model == "deepseek-chat"
|
|
assert routed.resolved.provider_model_ref == "deepseek/deepseek-chat"
|
|
|
|
|
|
def test_model_router_routes_wafer_provider_model_directly(settings):
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="wafer/DeepSeek-V4-Pro",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "DeepSeek-V4-Pro"
|
|
assert routed.resolved.provider_id == "wafer"
|
|
assert routed.resolved.provider_model == "DeepSeek-V4-Pro"
|
|
assert routed.resolved.provider_model_ref == "wafer/DeepSeek-V4-Pro"
|
|
|
|
|
|
def test_model_router_routes_minimax_provider_model_directly(settings):
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="minimax/MiniMax-M3",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "MiniMax-M3"
|
|
assert routed.resolved.provider_id == "minimax"
|
|
assert routed.resolved.provider_model == "MiniMax-M3"
|
|
assert routed.resolved.provider_model_ref == "minimax/MiniMax-M3"
|
|
|
|
|
|
def test_model_router_routes_gateway_encoded_provider_model_directly(settings):
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "deepseek-ai/deepseek-v4-pro"
|
|
assert (
|
|
routed.resolved.original_model
|
|
== "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro"
|
|
)
|
|
assert routed.resolved.provider_id == "nvidia_nim"
|
|
assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro"
|
|
assert (
|
|
routed.resolved.provider_model_ref
|
|
== "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro"
|
|
)
|
|
|
|
|
|
def test_model_router_routes_no_thinking_gateway_model_directly(settings):
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro",
|
|
max_tokens=100,
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.request.model == "deepseek-ai/deepseek-v4-pro"
|
|
assert (
|
|
routed.resolved.original_model
|
|
== "claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro"
|
|
)
|
|
assert routed.resolved.provider_id == "nvidia_nim"
|
|
assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro"
|
|
assert routed.reasoning.control is ReasoningControl.OFF
|
|
|
|
|
|
def test_direct_provider_model_uses_root_policy_without_model_name_guessing(settings):
|
|
settings.reasoning_policy = ReasoningPreference.LOW
|
|
settings.reasoning_opus = ReasoningPreference.MAX
|
|
|
|
routed = ModelRouter(settings).resolve_messages_request(
|
|
MessagesRequest(
|
|
model="open_router/anthropic/claude-opus-4",
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
)
|
|
|
|
assert routed.resolved.provider_id == "open_router"
|
|
assert routed.resolved.provider_model == "anthropic/claude-opus-4"
|
|
assert routed.reasoning.effort is ReasoningEffort.LOW
|
|
|
|
|
|
def test_model_router_routes_token_count_request(settings):
|
|
settings.model_haiku = "lmstudio/qwen2.5-7b"
|
|
|
|
request = TokenCountRequest(
|
|
model="claude-3-haiku-20240307",
|
|
messages=[Message(role="user", content="hello")],
|
|
)
|
|
routed = ModelRouter(settings).resolve_token_count_request(request)
|
|
|
|
assert routed.request.model == "qwen2.5-7b"
|
|
assert request.model == "claude-3-haiku-20240307"
|
|
|
|
|
|
def test_model_router_logs_mapping(settings):
|
|
with patch("free_claude_code.application.routing.logger.debug") as mock_log:
|
|
ModelRouter(settings).resolve("claude-2.1")
|
|
|
|
mock_log.assert_called()
|
|
args = mock_log.call_args[0]
|
|
assert "MODEL MAPPING" in args[0]
|
|
assert args[1] == "claude-2.1"
|
|
assert args[2] == "fallback-model"
|
|
|
|
|
|
def test_model_router_preserves_typed_error_for_unknown_mapped_provider(settings):
|
|
settings.model = "unknown/model"
|
|
|
|
with pytest.raises(UnknownProviderError) as exc_info:
|
|
ModelRouter(settings).resolve("claude-2.1")
|
|
|
|
supported = "', '".join(PROVIDER_CATALOG)
|
|
assert str(exc_info.value) == (
|
|
f"Unknown provider_type: 'unknown'. Supported: '{supported}'"
|
|
)
|