项目文件夹

文件
Ali Khokhar 6455c63e1d Make reasoning policy provider-neutral and client-aware (#1148)
## 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 -->
2026-07-16 19:57:12 -07:00

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}'"
)