diff --git a/.env.example b/.env.example index 033269e7e2dc9fb17be824bd8740381de22ad0ec..9e7857ec14734c3864a95547993eb0f844fc5958 100644 --- a/.env.example +++ b/.env.example @@ -139,14 +139,15 @@ FCC_SMOKE_OPENROUTER_FREE_MODELS= FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS= -# Thinking output -# Per-Claude-model switches for provider reasoning requests and Claude thinking blocks. -# Blank per-model switches inherit ENABLE_MODEL_THINKING. -ENABLE_FABLE_THINKING= -ENABLE_OPUS_THINKING= -ENABLE_SONNET_THINKING= -ENABLE_HAIKU_THINKING= -ENABLE_MODEL_THINKING=true +# Reasoning policy +# Root: off | client | low | medium | high | xhigh | max +# Route overrides additionally accept inherit. "client" preserves the CLI's effort; +# providers translate only controls documented by their API. +REASONING_POLICY=client +REASONING_FABLE=inherit +REASONING_OPUS=inherit +REASONING_SONNET=inherit +REASONING_HAIKU=inherit # Provider config diff --git a/AGENTS.md b/AGENTS.md index 164e80d21a8a67309dd5c23a20d2613496cc6798..0a2a65f830fbd7defdefbf62e004f305817dc1b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ - **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste. - **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside. - **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`. +- **Model-independent reasoning**: Resolve client reasoning intent once at the application boundary; provider adapters translate documented provider capabilities. Never branch on upstream model names or versions to choose reasoning behavior. - **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`). - **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters. - **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17a9c336ad652780d933478dc3892940813ea75c..bad4ffb99b969989810a357c1dc0c0af0c7cbd95 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -312,9 +312,15 @@ Model routing configuration is tiered: - `MODEL` is the fallback provider-prefixed model ref. - `MODEL_FABLE`, `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` override Claude model tiers. -- `ENABLE_MODEL_THINKING` is the global thinking switch. -- `ENABLE_FABLE_THINKING`, `ENABLE_OPUS_THINKING`, `ENABLE_SONNET_THINKING`, and - `ENABLE_HAIKU_THINKING` optionally override thinking by tier. +- `REASONING_POLICY` selects `off`, `client`, `low`, `medium`, `high`, `xhigh`, + or `max` for the fallback route. +- `REASONING_FABLE`, `REASONING_OPUS`, `REASONING_SONNET`, and + `REASONING_HAIKU` accept the same values plus `inherit`. + +[config/reasoning.py](src/free_claude_code/config/reasoning.py) owns the typed +configuration vocabulary. FCC-owned dotenv files receive a one-time rename and +value migration from the retired boolean settings; explicit `FCC_ENV_FILE` +files are never rewritten and instead receive an actionable startup warning. [config/model_refs.py](src/free_claude_code/config/model_refs.py) owns provider-prefixed model ref parsing and configured `MODEL*` inventory. API routing and provider validation @@ -434,7 +440,7 @@ sequenceDiagram Route->>Manager: acquire current generation Manager-->>Route: Lease(settings, provider resolver) Route->>Handler: create message - Handler->>Router: resolve model and thinking + Handler->>Router: resolve model and reasoning intent Handler->>Handler: server tools or optimizations Handler->>Exec: stream routed request Exec->>Lease: resolve provider @@ -465,10 +471,15 @@ If the incoming model is not direct, `ModelRouter` maps it by Claude tier. Names containing `fable`, `opus`, `sonnet`, or `haiku` use the matching tier override when set, otherwise they fall back to `MODEL`. -The router also resolves thinking. Gateway model IDs can force thinking on or -off; otherwise `ModelRouter` applies tier-specific thinking overrides or the -global setting. `ResolvedModel` carries only the selected route and thinking -decision; provider catalog metadata does not cross the application boundary. +The router also selects the applicable reasoning preference. Direct provider +refs use the root policy; Claude tier routes use a non-inherited tier override +or the root fallback; the no-thinking gateway variant forces `off`. +[application/reasoning.py](src/free_claude_code/application/reasoning.py) then +combines that preference with the concrete client request exactly once. The +resulting `ReasoningPolicy` preserves independent control, named effort, and an +exact client token budget without guessing provider behavior. `ResolvedModel` +owns the selected route and preference; `RoutedMessagesRequest` owns the final +request-scoped policy passed to execution. `GET /v1/models` advertises: @@ -480,9 +491,11 @@ decision; provider catalog metadata does not cross the application boundary. Provider model discovery and optional thinking metadata live in the application-level catalog owned by `ProviderRuntimeManager`. `ProviderModelInfo.supports_thinking` alone owns discovered per-model thinking -support; provider-wide capabilities do not model thinking. The catalog is not -part of an individual provider generation, so a hot replacement does not erase -the last useful model list. Discovery failures retain prior entries. +support for model-list presentation; it does not select request behavior. +Provider adapters must never branch on upstream model names or versions to +translate reasoning. The catalog is not part of an individual provider +generation, so a hot replacement does not erase the last useful model list. +Discovery failures retain prior entries. Codex-specific model picker shaping stays out of this route. `fcc-codex` fetches the same `/v1/models` response at launch, converts FCC gateway IDs into @@ -536,7 +549,7 @@ compatibility layer. [providers/base.py](src/free_claude_code/providers/base.py) defines provider-internal construction and lifecycle contracts: - `ProviderConfig`: shared provider settings such as API key, base URL, rate - limits, timeouts, proxy, thinking, and logging flags. It is a frozen internal + limits, timeouts, proxy, and logging flags. It is a frozen internal value whose base URL has already been resolved from the catalog. - `BaseProvider`: the abstract implementation base for cleanup, model listing, explicit preflight, and `stream_response()`. @@ -544,7 +557,8 @@ compatibility layer. There is one upstream provider family: [providers/openai_chat/](src/free_claude_code/providers/openai_chat/) implements the concrete `OpenAIChatProvider` used by every OpenAI-compatible `/chat/completions` -upstream. `OpenAIChatProfile` contains immutable request policy, its standard +upstream. `OpenAIChatProfile` contains immutable request policy, an explicit +reasoning encoder, an explicit history replay mode, its standard streamed-reasoning field, postprocessors, and base-URL normalization for ordinary vendors. Configuration differences therefore remain data rather than empty subclasses. The package also @@ -559,7 +573,7 @@ LM Studio composes the OpenAI-chat conversion first and its context-budget probe second; conversion failure therefore cannot open a stream or run the probe. Providers call the OpenAI request policy for Anthropic-to-OpenAI conversion, -thinking replay selection, `extra_body`, and chat-completion field normalization. +reasoning replay selection, `extra_body`, and chat-completion field normalization. Specialized provider packages remain only for true upstream quirks such as Gemini thought signatures, NIM tool-schema aliases, retry downgrades, and NVCF deployment-failure classification, or DeepSeek attachment/tool/thinking @@ -585,11 +599,47 @@ the GLM Coding Plan provider and uses Z.ai's Coding Plan OpenAI base. Mistral La Plateforme keeps its native `reasoning_effort` and thinking-chunk request/stream mapping inside [providers/mistral/reasoning.py](src/free_claude_code/providers/mistral/reasoning.py), including its -fallback retry when a selected Mistral model rejects reasoning fields. +fallback retry when an upstream request rejects reasoning fields. NIM reasoning budget control is also treated as a provider-owned best-effort downgrade: if an upstream NIM deployment rejects explicit budget control, FCC retries without the budget while preserving thinking enablement. +### Reasoning Ownership + +[core/reasoning.py](src/free_claude_code/core/reasoning.py) owns the immutable, +provider-neutral `ReasoningPolicy`. It represents three distinct facts: + +- `control`: provider default, explicitly off, or explicitly on; +- `effort`: the client's named effort when one was supplied; +- `budget_tokens`: an exact positive client budget, never a derived value. + +The application layer resolves configuration and client input into this value; +the API layer may replace it for a product policy such as the safety classifier; +providers receive it unchanged. Provider adapters alone translate the subset +their documented wire API can represent. The shared OpenAI-chat implementation +uses small encoder objects for named effort, reasoning objects, thinking +objects, chat-template booleans, exact llama.cpp budgets, and split reasoning +output. Specialized providers keep only translations that cannot be expressed +by those encoders. + +Reasoning history replay is a separate request-conversion decision. Every +profile explicitly chooses native `reasoning_content`, native `reasoning`, +`` tags, provider-specific chunks, or no replay. Turning off computation +for the next generation does not silently erase prior assistant state required +for a valid continuation. + +The boundary has four hard rules: + +1. Never inspect an upstream model name or version to select reasoning behavior. +2. Never convert a named effort into a fabricated token budget, or use the + output-token limit as a reasoning budget. +3. Forward an exact token budget only where the provider documents one; otherwise + translate a supported named or boolean control and leave unsupported precision + to the provider. +4. Provider-default intent emits no compute-control field. Explicit off requests + an upstream disable where supported and always suppresses reasoning output at + the FCC protocol boundary. + Shared provider responsibilities include upstream rate limiting, model listing, SDK/HTTP failure classification, safe diagnostic construction, HTTP resource cleanup, thinking/tool handling, retry or recovery where supported, and @@ -755,10 +805,11 @@ tools with a single string `input` field, and restores `custom_tool_call`, Responses edge. Text or grammar format metadata is preserved as model guidance; FCC does not validate custom-tool grammars. -Responses reasoning is handled as protocol conversion, not provider policy. -`reasoning.effort = "none"` converts to a disabled Anthropic `thinking` -request; any other explicit Responses reasoning request enables Anthropic -thinking without translating OpenAI effort names into Anthropic token budgets. +Responses reasoning is handled as lossless protocol conversion before provider +policy. The adapter preserves `reasoning.effort` in Anthropic `output_config`; +the application reasoning boundary then interprets `none` as off and preserves +all other named efforts. It never translates OpenAI effort names into Anthropic +token budgets. Prior Responses `reasoning` input items replay plaintext `reasoning_text`, or fallback `summary_text`, into assistant `reasoning_content`. Encrypted reasoning input is ignored because the proxy cannot decrypt it. @@ -794,7 +845,7 @@ handling. Each optimization is controlled by settings flags. Claude Code auto-mode safety-classifier requests are a message-only routing policy, not a short-circuit response. After routing, the Messages handler detects the -narrow classifier prompt shape and forces thinking off before provider execution +narrow classifier prompt shape and forces reasoning off before provider execution so Claude Code receives a parser-readable `yes` or `no` verdict. @@ -1321,7 +1372,7 @@ Update this file when a change adds or meaningfully changes: - startup, shutdown, or resource ownership; - configuration precedence or managed config behavior; - provider runtime, catalog, or upstream-adapter architecture; -- model routing or thinking behavior; +- model routing or reasoning behavior; - CLI adapter behavior; - messaging platform behavior; - protocol conversion or streaming contracts; diff --git a/CLAUDE.md b/CLAUDE.md index 164e80d21a8a67309dd5c23a20d2613496cc6798..0a2a65f830fbd7defdefbf62e004f305817dc1b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ - **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste. - **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside. - **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`. +- **Model-independent reasoning**: Resolve client reasoning intent once at the application boundary; provider adapters translate documented provider capabilities. Never branch on upstream model names or versions to choose reasoning behavior. - **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`). - **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters. - **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code. diff --git a/README.md b/README.md index 93134a18459d447f2530ae2a23f1789e2dfd5754..7c377a94e5324e6c70b8394acf6a623e405a81c5 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,12 @@ Use the tag shown by `ollama list` with the `ollama/` prefix. `OLLAMA_BASE_URL` For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`. +### Reasoning Control + +Open **Admin UI → Model Config → Reasoning** to choose how FCC handles client reasoning controls. The default **From client** option preserves reasoning effort sent by Claude Code, Codex, or Pi; when the client sends no control, the provider keeps its own default. + +You can instead select **Off**, **Low**, **Medium**, **High**, **X-High**, or **Max**. Fable, Opus, Sonnet, and Haiku each have the same choices plus **Inherit**, which uses the root policy. FCC translates each choice only into controls documented by that provider, so unsupported precision safely remains provider-defined. + ## Connect Your Client diff --git a/pyproject.toml b/pyproject.toml index a211fcb4d3aaa96a2afca3bf804120f9be2ea734..a62d32ccf3e1d459b81071e60f8bae0e27c25f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "4.7.3" +version = "4.8.0" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/smoke/capabilities.py b/smoke/capabilities.py index dce13c1c397facca6d1545e373fd2a24aada8aeb..10c31e0ca8abdf94d1aa59273a5700c7aa5fdc9c 100644 --- a/smoke/capabilities.py +++ b/smoke/capabilities.py @@ -201,7 +201,7 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( "tests/providers/test_open_router.py", ), ( - "test_per_model_thinking_config_e2e", + "test_route_reasoning_config_e2e", "test_provider_reasoning_tool_continuation_e2e", ), ), @@ -269,10 +269,11 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( "removed_env_migration", "removed_env_migration", "free_claude_code.config.settings.Settings", - "NIM_ENABLE_THINKING or ENABLE_THINKING in env or dotenv", - "startup succeeds and stale keys do not change thinking defaults", - "removed key ignored", - ("tests/config/test_config.py",), + "retired ENABLE_MODEL_THINKING or tier override in an FCC-owned dotenv", + "owned dotenv is migrated to the typed reasoning policy", + "explicit dotenv receives an actionable warning", + ("tests/config/test_env_migrations.py", "tests/config/test_config.py"), + ("test_removed_env_migration_e2e",), ), CapabilityContract( "provider_runtime", diff --git a/smoke/features.py b/smoke/features.py index d01ebafbd6b785edfaccdf095700b25ffe288fed..ce5805e14adb752bca2da0b33def1c31af8b00b1 100644 --- a/smoke/features.py +++ b/smoke/features.py @@ -157,7 +157,7 @@ FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = ( "test_provider_reasoning_tool_continuation_e2e", "test_gemini_thought_signature_tool_continuation_e2e", "test_claude_cli_adaptive_thinking_e2e", - "test_per_model_thinking_config_e2e", + "test_route_reasoning_config_e2e", ), ("providers", "cli", "config"), ("configured provider",), diff --git a/smoke/product/test_config_extensibility_product_live.py b/smoke/product/test_config_extensibility_product_live.py index f23ea036673d9b68f62392d55d3d8a772dff0e99..73d90a7d6463c299741a0031ce68b6296bebe4a4 100644 --- a/smoke/product/test_config_extensibility_product_live.py +++ b/smoke/product/test_config_extensibility_product_live.py @@ -64,14 +64,14 @@ def test_removed_env_migration_e2e(smoke_config: SmokeConfig, tmp_path) -> None: @pytest.mark.smoke_target("config") -def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: +def test_route_reasoning_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: env_file = tmp_path / "thinking.env" env_file.write_text( - 'ENABLE_MODEL_THINKING="false"\n' - 'ENABLE_FABLE_THINKING="true"\n' - 'ENABLE_OPUS_THINKING="true"\n' - "ENABLE_SONNET_THINKING=\n" - 'ENABLE_HAIKU_THINKING="false"\n', + 'REASONING_POLICY="off"\n' + 'REASONING_FABLE="high"\n' + 'REASONING_OPUS="client"\n' + 'REASONING_SONNET="inherit"\n' + 'REASONING_HAIKU="off"\n', encoding="utf-8", ) env = os.environ.copy() @@ -81,11 +81,11 @@ def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> N "from free_claude_code.config.settings import Settings; " "s=Settings(); " "r=ModelRouter(s); " - "print(r.resolve('claude-fable-5').thinking_enabled); " - "print(r.resolve('claude-opus-4-20250514').thinking_enabled); " - "print(r.resolve('claude-sonnet-4-20250514').thinking_enabled); " - "print(r.resolve('claude-haiku-4-20250514').thinking_enabled); " - "print(r.resolve('unknown-model').thinking_enabled)" + "print(r.resolve('claude-fable-5').reasoning_preference.value); " + "print(r.resolve('claude-opus-4-20250514').reasoning_preference.value); " + "print(r.resolve('claude-sonnet-4-20250514').reasoning_preference.value); " + "print(r.resolve('claude-haiku-4-20250514').reasoning_preference.value); " + "print(r.resolve('unknown-model').reasoning_preference.value)" ) result = run_captured_text( cmd_python_c(script), @@ -96,11 +96,11 @@ def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> N ) assert result.returncode == 0, result.stderr assert result.stdout.splitlines() == [ - "True", - "True", - "False", - "False", - "False", + "high", + "client", + "off", + "off", + "off", ] diff --git a/smoke/product/test_nvidia_nim_cli_product_live.py b/smoke/product/test_nvidia_nim_cli_product_live.py index 910c3799c1952db3d0babbab002cba7e1b60a980..706e91227a3c15da695615fbd7c383f49ef0529e 100644 --- a/smoke/product/test_nvidia_nim_cli_product_live.py +++ b/smoke/product/test_nvidia_nim_cli_product_live.py @@ -35,7 +35,7 @@ def test_nvidia_nim_cli_matrix_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> env_overrides={ "MODEL": provider_model.full_model, "MESSAGING_PLATFORM": "none", - "ENABLE_MODEL_THINKING": "true", + "REASONING_POLICY": "high", "LOG_RAW_API_PAYLOADS": "true", "LOG_RAW_SSE_EVENTS": "true", }, diff --git a/smoke/product/test_openrouter_free_cli_product_live.py b/smoke/product/test_openrouter_free_cli_product_live.py index 994803b1e59abee6a349458afa19299f6f6bec77..840314917d085710f20968e4f19cd7ae561bfdee 100644 --- a/smoke/product/test_openrouter_free_cli_product_live.py +++ b/smoke/product/test_openrouter_free_cli_product_live.py @@ -37,7 +37,7 @@ def test_openrouter_free_cli_matrix_e2e( env_overrides={ "MODEL": provider_model.full_model, "MESSAGING_PLATFORM": "none", - "ENABLE_MODEL_THINKING": "true", + "REASONING_POLICY": "high", "LOG_RAW_API_PAYLOADS": "true", "LOG_RAW_SSE_EVENTS": "true", }, diff --git a/smoke/product/test_provider_product_live.py b/smoke/product/test_provider_product_live.py index 0da461ba5d8adb3f0f84c56424cf3d8723110307..0c0fda357196f5c5ff8754da1f32cf7d62540a70 100644 --- a/smoke/product/test_provider_product_live.py +++ b/smoke/product/test_provider_product_live.py @@ -4,6 +4,7 @@ import httpx import pytest from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.reasoning import ReasoningPreference from free_claude_code.core.anthropic.stream_contracts import ( SSEEvent, parse_sse_lines, @@ -101,8 +102,8 @@ def test_gemini_thought_signature_tool_continuation_e2e( def test_provider_reasoning_tool_continuation_e2e( smoke_config: SmokeConfig, provider_model: ProviderModel ) -> None: - if not _provider_smoke_thinking_enabled(smoke_config): - pytest.skip("the configured Claude route does not enable thinking") + if not _provider_smoke_reasoning_enabled(smoke_config): + pytest.skip("the configured Claude route disables reasoning") _run_provider_scenario( smoke_config, provider_model, _scenario_reasoning_tool_continuation ) @@ -267,11 +268,12 @@ def _tool_use_blocks_or_skip( return blocks -def _provider_smoke_thinking_enabled(smoke_config: SmokeConfig) -> bool: +def _provider_smoke_reasoning_enabled(smoke_config: SmokeConfig) -> bool: return ( ModelRouter(smoke_config.settings) .resolve("claude-sonnet-4-5-20250929") - .thinking_enabled + .reasoning_preference + is not ReasoningPreference.OFF ) diff --git a/smoke/product/test_runtime_ownership_product_live.py b/smoke/product/test_runtime_ownership_product_live.py index 09cac6e2fca5e92a51ae2aa937ce955a1e611a58..e6ca2336d035ab4d8ebf214bc5a2482a478d7a29 100644 --- a/smoke/product/test_runtime_ownership_product_live.py +++ b/smoke/product/test_runtime_ownership_product_live.py @@ -173,8 +173,21 @@ def _message_payload(*, stream: bool) -> dict[str, Any]: def _write_initial_managed_config(home: Path, upstream: FakeOpenAIUpstream) -> None: config_path = home / ".fcc" / ".env" config_path.parent.mkdir(parents=True, exist_ok=True) + template = "\n".join( + line + for line in load_env_template().splitlines() + if not line.startswith( + ( + "REASONING_POLICY=", + "REASONING_FABLE=", + "REASONING_OPUS=", + "REASONING_SONNET=", + "REASONING_HAIKU=", + ) + ) + ) config_path.write_text( - load_env_template() + template + "\n" + "\n".join( [ @@ -245,10 +258,19 @@ def test_provider_hot_swap_preserves_inflight_stream_e2e( "MODEL_HAIKU", "MODEL_OPUS", "MODEL_SONNET", + "REASONING_FABLE", + "REASONING_HAIKU", + "REASONING_OPUS", + "REASONING_POLICY", + "REASONING_SONNET", }, ) ) + managed_config = (home / ".fcc" / ".env").read_text(encoding="utf-8") + assert "REASONING_POLICY=off" in managed_config + assert "ENABLE_MODEL_THINKING" not in managed_config + def consume_old_stream() -> None: try: with httpx.stream( diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js index 1d8c9044209c50788fb1d68d9a28ca88fdd8870f..c4124a07c671196810e3d3ff29c8f7bd83219883 100644 --- a/src/free_claude_code/api/admin_static/admin.js +++ b/src/free_claude_code/api/admin_static/admin.js @@ -20,7 +20,7 @@ const VIEW_GROUPS = [ id: "model_config", label: "Model Config", title: "Model Config", - sections: ["models", "thinking", "web_tools"], + sections: ["models", "reasoning", "web_tools"], containerId: "modelConfigSections", }, { @@ -308,21 +308,12 @@ function inputForField(field) { return input; } - if (field.type === "tri_boolean") { - const select = document.createElement("select"); - [ - ["", "Inherit"], - ["true", "Enabled"], - ["false", "Disabled"], - ].forEach(([value, label]) => select.appendChild(option(value, label))); - select.value = field.value || ""; - return select; - } - if (field.type === "select") { const select = document.createElement("select"); - field.options.forEach((value) => select.appendChild(option(value, value))); - select.value = field.value || field.options[0] || ""; + field.options.forEach((item) => + select.appendChild(option(item.value, item.label)), + ); + select.value = field.value || field.options[0]?.value || ""; return select; } diff --git a/src/free_claude_code/api/handlers/messages.py b/src/free_claude_code/api/handlers/messages.py index b5a86aeb30542f52df6bf11460fa2f74debcfd96..840cb57455b8e9906d109131fc7553709a13267b 100644 --- a/src/free_claude_code/api/handlers/messages.py +++ b/src/free_claude_code/api/handlers/messages.py @@ -47,6 +47,7 @@ from free_claude_code.core.anthropic import ( ) from free_claude_code.core.diagnostics import safe_exception_message from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.reasoning import ReasoningControl, ReasoningPolicy from free_claude_code.core.trace import trace_event @@ -271,7 +272,7 @@ class MessagesHandler: ) -> RoutedMessagesRequest: if not is_safety_classifier_request(routed.request): return routed - changed = routed.resolved.thinking_enabled + changed = routed.reasoning.control is not ReasoningControl.OFF trace_event( stage="routing", event="free_claude_code.api.optimization.safety_classifier_no_thinking", @@ -281,10 +282,7 @@ class MessagesHandler: ) if not changed: return routed - return RoutedMessagesRequest( - request=routed.request, - resolved=replace(routed.resolved, thinking_enabled=False), - ) + return replace(routed, reasoning=ReasoningPolicy.off()) def _run_message_intercepts( self, routed: RoutedMessagesRequest diff --git a/src/free_claude_code/application/execution.py b/src/free_claude_code/application/execution.py index 0e1c3f523878c47da7df63e46fcc6f1128ae5455..c7ac07b56e0a002740f6acace76fdad95ef75bda 100644 --- a/src/free_claude_code/application/execution.py +++ b/src/free_claude_code/application/execution.py @@ -58,7 +58,7 @@ class ProviderExecutor: provider = self._provider_resolver(routed.resolved.provider_id) provider.preflight_stream( routed.request, - thinking_enabled=routed.resolved.thinking_enabled, + reasoning=routed.reasoning, ) route_trace: dict[str, object] = { @@ -70,7 +70,13 @@ class ProviderExecutor: "provider_model": routed.resolved.provider_model, "provider_model_ref": routed.resolved.provider_model_ref, "gateway_model": routed.request.model, - "thinking_enabled": routed.resolved.thinking_enabled, + "reasoning_control": routed.reasoning.control.value, + "reasoning_effort": ( + routed.reasoning.effort.value + if routed.reasoning.effort is not None + else None + ), + "reasoning_budget_tokens": routed.reasoning.budget_tokens, } if wire_api == "responses": route_trace["wire_api"] = "responses" @@ -107,7 +113,7 @@ class ProviderExecutor: routed.request, input_tokens=input_tokens, request_id=request_id, - thinking_enabled=routed.resolved.thinking_enabled, + reasoning=routed.reasoning, ) async for chunk in provider_stream: yield chunk diff --git a/src/free_claude_code/application/ports.py b/src/free_claude_code/application/ports.py index 0f83ceeec7f78465e8f7cbfd2c2e9d39c7c61db0..0b6063f14e09238d0191120e1c5c9d12e42685a6 100644 --- a/src/free_claude_code/application/ports.py +++ b/src/free_claude_code/application/ports.py @@ -6,6 +6,7 @@ from typing import Protocol from free_claude_code.config.settings import Settings from free_claude_code.core.anthropic import MessagesRequest +from free_claude_code.core.reasoning import ReasoningPolicy from .model_metadata import ProviderModelInfo @@ -17,7 +18,7 @@ class ProviderPort(Protocol): self, request: MessagesRequest, *, - thinking_enabled: bool, + reasoning: ReasoningPolicy, ) -> None: ... def stream_response( @@ -26,7 +27,7 @@ class ProviderPort(Protocol): *, input_tokens: int, request_id: str, - thinking_enabled: bool, + reasoning: ReasoningPolicy, ) -> AsyncIterator[str]: ... diff --git a/src/free_claude_code/application/reasoning.py b/src/free_claude_code/application/reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..7cd495a44acaaa8d08db52355c4a90f3f205ed61 --- /dev/null +++ b/src/free_claude_code/application/reasoning.py @@ -0,0 +1,99 @@ +"""Resolve client reasoning input and FCC configuration exactly once.""" + +from collections.abc import Mapping +from typing import Any + +from free_claude_code.config.reasoning import ReasoningPreference +from free_claude_code.core.anthropic.models import MessagesRequest, ThinkingConfig +from free_claude_code.core.reasoning import ( + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) + + +def resolve_reasoning_policy( + request: MessagesRequest, + preference: ReasoningPreference, +) -> ReasoningPolicy: + """Apply one resolved configuration preference to the client request.""" + + if preference is ReasoningPreference.INHERIT: + raise ValueError("Reasoning preference must be resolved before application.") + if preference is ReasoningPreference.OFF: + return ReasoningPolicy.off() + if preference is not ReasoningPreference.CLIENT: + return ReasoningPolicy.on(effort=ReasoningEffort(preference.value)) + return client_reasoning_policy(request) + + +def client_reasoning_policy(request: MessagesRequest) -> ReasoningPolicy: + """Return the lossless reasoning intent expressed by one client request.""" + + budget_tokens = _positive_budget(request.thinking) + thinking_control = _thinking_control( + request.thinking, + budget_tokens=budget_tokens, + ) + effort, effort_disables = _output_effort(request.output_config) + + if effort_disables: + return ReasoningPolicy.off() + if thinking_control is ReasoningControl.OFF: + return ReasoningPolicy( + control=ReasoningControl.OFF, + effort=effort, + ) + if thinking_control is ReasoningControl.ON or budget_tokens is not None: + return ReasoningPolicy.on( + effort=effort, + budget_tokens=budget_tokens, + ) + return ReasoningPolicy( + control=ReasoningControl.DEFAULT, + effort=effort, + ) + + +def _thinking_control( + thinking: ThinkingConfig | None, + *, + budget_tokens: int | None, +) -> ReasoningControl: + if thinking is None: + return ReasoningControl.DEFAULT + if thinking.type == "disabled" or ( + "enabled" in thinking.model_fields_set and thinking.enabled is False + ): + return ReasoningControl.OFF + if ( + thinking.type in {"adaptive", "enabled"} + or ("enabled" in thinking.model_fields_set and thinking.enabled is True) + or budget_tokens is not None + ): + return ReasoningControl.ON + return ReasoningControl.DEFAULT + + +def _output_effort(value: Any) -> tuple[ReasoningEffort | None, bool]: + if not isinstance(value, Mapping): + return None, False + raw = value.get("effort") + if not isinstance(raw, str): + return None, False + normalized = raw.strip().lower() + if normalized == "none": + return None, True + try: + return ReasoningEffort(normalized), False + except ValueError: + return None, False + + +def _positive_budget(thinking: ThinkingConfig | None) -> int | None: + if thinking is None: + return None + value = thinking.budget_tokens + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + return None diff --git a/src/free_claude_code/application/routing.py b/src/free_claude_code/application/routing.py index 5c5c8e1ea4c7cc2949e349fcb0aeb9a9d899d87a..898656ac08df65a9a958aadecc3fbd9df654edd5 100644 --- a/src/free_claude_code/application/routing.py +++ b/src/free_claude_code/application/routing.py @@ -10,9 +10,20 @@ from free_claude_code.config.provider_catalog import ( PROVIDER_CATALOG, SUPPORTED_PROVIDER_IDS, ) +from free_claude_code.config.reasoning import ReasoningPreference from free_claude_code.config.settings import Settings from free_claude_code.core.anthropic import MessagesRequest, TokenCountRequest from free_claude_code.core.gateway_model_ids import decode_gateway_model_id +from free_claude_code.core.reasoning import ReasoningPolicy + +from .reasoning import resolve_reasoning_policy + +_ROUTE_SETTINGS = ( + ("fable", "model_fable", "reasoning_fable"), + ("opus", "model_opus", "reasoning_opus"), + ("haiku", "model_haiku", "reasoning_haiku"), + ("sonnet", "model_sonnet", "reasoning_sonnet"), +) @dataclass(frozen=True, slots=True) @@ -21,13 +32,14 @@ class ResolvedModel: provider_id: str provider_model: str provider_model_ref: str - thinking_enabled: bool + reasoning_preference: ReasoningPreference @dataclass(frozen=True, slots=True) class RoutedMessagesRequest: request: MessagesRequest resolved: ResolvedModel + reasoning: ReasoningPolicy @dataclass(frozen=True, slots=True) @@ -46,31 +58,31 @@ class ModelRouter: ( direct_provider_id, direct_provider_model, - force_thinking_enabled, + force_reasoning_off, ) = self._direct_provider_model(claude_model_name) if direct_provider_id is not None and direct_provider_model is not None: - thinking_enabled = ( - force_thinking_enabled - if force_thinking_enabled is not None - else self._resolve_thinking(direct_provider_model) + reasoning_preference = ( + ReasoningPreference.OFF + if force_reasoning_off + else self._settings.reasoning_policy ) logger.debug( - "MODEL DIRECT: '{}' -> provider='{}' model='{}' thinking={}", + "MODEL DIRECT: '{}' -> provider='{}' model='{}' reasoning={}", claude_model_name, direct_provider_id, direct_provider_model, - thinking_enabled, + reasoning_preference.value, ) return ResolvedModel( original_model=claude_model_name, provider_id=direct_provider_id, provider_model=direct_provider_model, provider_model_ref=claude_model_name, - thinking_enabled=thinking_enabled, + reasoning_preference=reasoning_preference, ) provider_model_ref = self._resolve_model_ref(claude_model_name) - thinking_enabled = self._resolve_thinking(claude_model_name) + reasoning_preference = self._resolve_reasoning_preference(claude_model_name) provider_id = parse_provider_type(provider_model_ref) self._validate_provider_id(provider_id) provider_model = parse_model_name(provider_model_ref) @@ -83,7 +95,7 @@ class ModelRouter: provider_id=provider_id, provider_model=provider_model, provider_model_ref=provider_model_ref, - thinking_enabled=thinking_enabled, + reasoning_preference=reasoning_preference, ) @staticmethod @@ -93,53 +105,55 @@ class ModelRouter: def _direct_provider_model( self, model_name: str - ) -> tuple[str | None, str | None, bool | None]: + ) -> tuple[str | None, str | None, bool]: decoded = decode_gateway_model_id(model_name) if decoded is not None: if decoded.provider_id not in SUPPORTED_PROVIDER_IDS: - return None, None, None + return None, None, False return ( decoded.provider_id, decoded.provider_model, - decoded.force_thinking_enabled, + decoded.force_reasoning_off, ) provider_id, separator, provider_model = model_name.partition("/") if not separator: - return None, None, None + return None, None, False if provider_id not in SUPPORTED_PROVIDER_IDS: - return None, None, None + return None, None, False if not provider_model: - return None, None, None - return provider_id, provider_model, None + return None, None, False + return provider_id, provider_model, False def _resolve_model_ref(self, claude_model_name: str) -> str: """Resolve a Claude model name to the configured provider/model ref.""" - name_lower = claude_model_name.lower() - if "fable" in name_lower and self._settings.model_fable is not None: - return self._settings.model_fable - if "opus" in name_lower and self._settings.model_opus is not None: - return self._settings.model_opus - if "haiku" in name_lower and self._settings.model_haiku is not None: - return self._settings.model_haiku - if "sonnet" in name_lower and self._settings.model_sonnet is not None: - return self._settings.model_sonnet + route = self._matched_route(claude_model_name) + if route is not None: + model = getattr(self._settings, route[1]) + if isinstance(model, str): + return model return self._settings.model - def _resolve_thinking(self, claude_model_name: str) -> bool: - """Resolve whether thinking is enabled for an incoming Claude model name.""" + def _resolve_reasoning_preference( + self, claude_model_name: str + ) -> ReasoningPreference: + """Resolve a route override without inspecting the provider model.""" - name_lower = claude_model_name.lower() - if "fable" in name_lower and self._settings.enable_fable_thinking is not None: - return self._settings.enable_fable_thinking - if "opus" in name_lower and self._settings.enable_opus_thinking is not None: - return self._settings.enable_opus_thinking - if "haiku" in name_lower and self._settings.enable_haiku_thinking is not None: - return self._settings.enable_haiku_thinking - if "sonnet" in name_lower and self._settings.enable_sonnet_thinking is not None: - return self._settings.enable_sonnet_thinking - return self._settings.enable_model_thinking + route = self._matched_route(claude_model_name) + if route is not None: + preference = getattr(self._settings, route[2]) + if preference is not ReasoningPreference.INHERIT: + return preference + return self._settings.reasoning_policy + + @staticmethod + def _matched_route(model_name: str) -> tuple[str, str, str] | None: + normalized = model_name.lower() + return next( + (route for route in _ROUTE_SETTINGS if route[0] in normalized), + None, + ) def resolve_messages_request( self, request: MessagesRequest @@ -148,7 +162,14 @@ class ModelRouter: resolved = self.resolve(request.model) routed = request.model_copy(deep=True) routed.model = resolved.provider_model - return RoutedMessagesRequest(request=routed, resolved=resolved) + return RoutedMessagesRequest( + request=routed, + resolved=resolved, + reasoning=resolve_reasoning_policy( + routed, + resolved.reasoning_preference, + ), + ) def resolve_token_count_request( self, request: TokenCountRequest diff --git a/src/free_claude_code/cli/commands.py b/src/free_claude_code/cli/commands.py index a4c77dfc089b1c3f48131719fbf7442e1265eefc..c31c8042b36304ca00990fd64a12f5220b9f5002 100644 --- a/src/free_claude_code/cli/commands.py +++ b/src/free_claude_code/cli/commands.py @@ -13,7 +13,7 @@ import uvicorn from free_claude_code.cli.launchers.common import preflight_proxy from free_claude_code.cli.process_registry import kill_all_best_effort from free_claude_code.config.env_migrations import ( - explicit_env_file_huggingface_warning, + explicit_env_file_migration_warning, migrate_owned_env_files, ) from free_claude_code.config.env_template import load_env_template @@ -148,6 +148,6 @@ def _migrate_config_env_keys() -> tuple[Path, ...]: """Apply dotenv key migrations before Settings loads config.""" migrated = migrate_owned_env_files() - if warning := explicit_env_file_huggingface_warning(os.environ): + if warning := explicit_env_file_migration_warning(os.environ): print(warning, file=sys.stderr) return migrated diff --git a/src/free_claude_code/config/admin/manifest.py b/src/free_claude_code/config/admin/manifest.py index 0067d993354a02014ba902839ec214086f5168f6..0a8a9dcd48b15de6ced61fa3a90ebf41181ee3f1 100644 --- a/src/free_claude_code/config/admin/manifest.py +++ b/src/free_claude_code/config/admin/manifest.py @@ -4,6 +4,11 @@ from collections.abc import Iterable from dataclasses import dataclass from typing import Literal +from free_claude_code.config.reasoning import ( + ROOT_REASONING_PREFERENCES, + ROUTE_REASONING_PREFERENCES, + ReasoningPreference, +) from free_claude_code.config.settings import Settings from .provider_manifest import provider_field_specs @@ -13,7 +18,6 @@ FieldType = Literal[ "secret", "number", "boolean", - "tri_boolean", "model", "optional_model", "select", @@ -41,7 +45,7 @@ class ConfigFieldSpec: field_type: FieldType = "text" settings_attr: str | None = None default: str = "" - options: tuple[str, ...] = () + options: tuple[str | ConfigOptionSpec, ...] = () secret: bool = False advanced: bool = False restart_required: bool = False @@ -49,6 +53,33 @@ class ConfigFieldSpec: description: str = "" +@dataclass(frozen=True, slots=True) +class ConfigOptionSpec: + """A persisted option value and its user-facing label.""" + + value: str + label: str + + +def _reasoning_options( + preferences: tuple[ReasoningPreference, ...], +) -> tuple[ConfigOptionSpec, ...]: + labels = { + ReasoningPreference.INHERIT: "Inherit", + ReasoningPreference.OFF: "Off", + ReasoningPreference.CLIENT: "From client", + ReasoningPreference.LOW: "Low", + ReasoningPreference.MEDIUM: "Medium", + ReasoningPreference.HIGH: "High", + ReasoningPreference.XHIGH: "X-High", + ReasoningPreference.MAX: "Max", + } + return tuple( + ConfigOptionSpec(preference.value, labels[preference]) + for preference in preferences + ) + + SECTIONS: tuple[ConfigSectionSpec, ...] = ( ConfigSectionSpec( "providers", @@ -61,9 +92,9 @@ SECTIONS: tuple[ConfigSectionSpec, ...] = ( "Search discovered provider models or enter a provider/model slug.", ), ConfigSectionSpec( - "thinking", - "Thinking", - "Global and tier-specific thinking behavior.", + "reasoning", + "Reasoning", + "Client reasoning policy and route-specific overrides.", ), ConfigSectionSpec( "runtime", @@ -143,44 +174,53 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = ( description="Select None to use the Default Model for Haiku requests.", ), ConfigFieldSpec( - "ENABLE_MODEL_THINKING", - "Enable Thinking", - "thinking", - "boolean", - settings_attr="enable_model_thinking", - default="true", - ), - ConfigFieldSpec( - "ENABLE_FABLE_THINKING", - "Fable Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_fable_thinking", - description="Blank inherits Enable Thinking.", + "REASONING_POLICY", + "Reasoning Policy", + "reasoning", + "select", + settings_attr="reasoning_policy", + default="client", + options=_reasoning_options(ROOT_REASONING_PREFERENCES), + description=( + "From client preserves CLI effort. Providers translate only the controls " + "their API supports." + ), + ), + ConfigFieldSpec( + "REASONING_FABLE", + "Fable Reasoning", + "reasoning", + "select", + settings_attr="reasoning_fable", + default="inherit", + options=_reasoning_options(ROUTE_REASONING_PREFERENCES), ), ConfigFieldSpec( - "ENABLE_OPUS_THINKING", - "Opus Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_opus_thinking", - description="Blank inherits Enable Thinking.", + "REASONING_OPUS", + "Opus Reasoning", + "reasoning", + "select", + settings_attr="reasoning_opus", + default="inherit", + options=_reasoning_options(ROUTE_REASONING_PREFERENCES), ), ConfigFieldSpec( - "ENABLE_SONNET_THINKING", - "Sonnet Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_sonnet_thinking", - description="Blank inherits Enable Thinking.", + "REASONING_SONNET", + "Sonnet Reasoning", + "reasoning", + "select", + settings_attr="reasoning_sonnet", + default="inherit", + options=_reasoning_options(ROUTE_REASONING_PREFERENCES), ), ConfigFieldSpec( - "ENABLE_HAIKU_THINKING", - "Haiku Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_haiku_thinking", - description="Blank inherits Enable Thinking.", + "REASONING_HAIKU", + "Haiku Reasoning", + "reasoning", + "select", + settings_attr="reasoning_haiku", + default="inherit", + options=_reasoning_options(ROUTE_REASONING_PREFERENCES), ), ConfigFieldSpec( "ANTHROPIC_AUTH_TOKEN", diff --git a/src/free_claude_code/config/admin/values.py b/src/free_claude_code/config/admin/values.py index 278189452b6ce71678239c1a4faf6c22790e2247..328bf7011c756faad5caf98adcfae4d7c52e79df 100644 --- a/src/free_claude_code/config/admin/values.py +++ b/src/free_claude_code/config/admin/values.py @@ -5,7 +5,13 @@ from typing import Any from free_claude_code.config.paths import managed_env_path -from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .manifest import ( + FIELD_BY_KEY, + FIELDS, + SECTIONS, + ConfigFieldSpec, + ConfigOptionSpec, +) from .sources import ( configured_env_files, dotenv_values_from_file, @@ -88,7 +94,14 @@ def load_config_response() -> dict[str, Any]: "advanced": field.advanced, "restart_required": field.restart_required, "session_sensitive": field.session_sensitive, - "options": list(field.options), + "options": [ + ( + {"value": option.value, "label": option.label} + if isinstance(option, ConfigOptionSpec) + else {"value": option, "label": option} + ) + for option in field.options + ], "description": field.description, } ) diff --git a/src/free_claude_code/config/env_migrations.py b/src/free_claude_code/config/env_migrations.py index 91279b92dae33046ae44b10ef092198181ecb877..18c71ecc561542070fbb84b41bd82785e75c9dcb 100644 --- a/src/free_claude_code/config/env_migrations.py +++ b/src/free_claude_code/config/env_migrations.py @@ -22,6 +22,7 @@ class EnvKeyMigration: old_key: str new_key: str + value_map: tuple[tuple[str, str], ...] = () HUGGINGFACE_TOKEN_MIGRATION = EnvKeyMigration( @@ -29,32 +30,67 @@ HUGGINGFACE_TOKEN_MIGRATION = EnvKeyMigration( new_key=HUGGINGFACE_API_KEY_ENV, ) +_LEGACY_TRUE_VALUES = ("1", "true", "t", "on", "yes", "y") +_LEGACY_FALSE_VALUES = ("0", "false", "f", "off", "no", "n") +_LEGACY_REASONING_BOOLEAN_MAP = ( + *((value, "client") for value in _LEGACY_TRUE_VALUES), + *((value, "off") for value in _LEGACY_FALSE_VALUES), +) + +REASONING_MIGRATIONS = ( + EnvKeyMigration( + "ENABLE_MODEL_THINKING", + "REASONING_POLICY", + _LEGACY_REASONING_BOOLEAN_MAP, + ), + *( + EnvKeyMigration( + f"ENABLE_{route}_THINKING", + f"REASONING_{route}", + (("", "inherit"), *_LEGACY_REASONING_BOOLEAN_MAP), + ) + for route in ("FABLE", "OPUS", "SONNET", "HAIKU") + ), +) + +ENV_MIGRATIONS = (HUGGINGFACE_TOKEN_MIGRATION, *REASONING_MIGRATIONS) + def migrate_owned_env_files() -> tuple[Path, ...]: """Apply key migrations to repo and managed dotenv files.""" - return tuple( - path.resolve() - for path in _unique_paths((repo_env_path(), managed_env_path())) - if migrate_env_key_in_file(path, HUGGINGFACE_TOKEN_MIGRATION) - ) + changed_paths: list[Path] = [] + for path in _unique_paths((repo_env_path(), managed_env_path())): + changed = False + for migration in ENV_MIGRATIONS: + changed = migrate_env_key_in_file(path, migration) or changed + if changed: + changed_paths.append(path.resolve()) + return tuple(changed_paths) -def explicit_env_file_huggingface_warning( +def explicit_env_file_migration_warning( env: Mapping[str, str] | None = None, ) -> str | None: - """Return a warning when an explicit env file still uses ``HF_TOKEN``.""" + """Return a warning when an explicit env file uses a retired setting.""" path = explicit_env_path(env) if path is None or not path.is_file(): return None text = path.read_text(encoding="utf-8") - if not env_text_needs_migration(text, HUGGINGFACE_TOKEN_MIGRATION): + pending = tuple( + migration + for migration in ENV_MIGRATIONS + if env_text_needs_migration(text, migration) + ) + if not pending: return None + renames = ", ".join( + f"{migration.old_key} to {migration.new_key}" for migration in pending + ) return ( - f"{LEGACY_HUGGINGFACE_TOKEN_ENV} is set in explicit FCC_ENV_FILE {path}. " - f"Rename it to {HUGGINGFACE_API_KEY_ENV}; explicit env files are not " - "rewritten automatically." + f"Explicit FCC_ENV_FILE {path} uses retired settings. Rename {renames}; " + "explicit env files are not rewritten automatically." ) @@ -86,9 +122,12 @@ def migrate_env_key_in_text( match = _DOTENV_ASSIGNMENT_RE.match(line) if match is None or match.group("key") != migration.old_key: continue + remainder = line[match.end() :] + if migration.value_map: + remainder = _mapped_value(remainder, migration.value_map) lines[index] = ( f"{match.group('prefix')}{migration.new_key}{match.group('suffix')}" - f"{line[match.end() :]}" + f"{remainder}" ) changed = True if not changed: @@ -114,6 +153,20 @@ def _defines_key(text: str, key: str) -> bool: return False +def _mapped_value(value: str, mapping: tuple[tuple[str, str], ...]) -> str: + """Map a simple dotenv value while preserving comments and line endings.""" + + line = value.rstrip("\r\n") + newline = value[len(line) :] + raw_value, separator, comment = line.partition("#") + normalized = raw_value.strip().strip("'\"").lower() + replacement = dict(mapping).get(normalized) + if replacement is None: + return value + suffix = f" #{comment}" if separator else "" + return f"{replacement}{suffix}{newline}" + + def _unique_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]: seen: set[Path] = set() unique: list[Path] = [] diff --git a/src/free_claude_code/config/reasoning.py b/src/free_claude_code/config/reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..9957c1653a6955946d9408b83e75a14fa5bb7216 --- /dev/null +++ b/src/free_claude_code/config/reasoning.py @@ -0,0 +1,24 @@ +"""User-configurable reasoning policy values.""" + +from enum import StrEnum + + +class ReasoningPreference(StrEnum): + """Configuration choice applied before provider translation.""" + + INHERIT = "inherit" + OFF = "off" + CLIENT = "client" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + XHIGH = "xhigh" + MAX = "max" + + +ROOT_REASONING_PREFERENCES = tuple( + preference + for preference in ReasoningPreference + if preference is not ReasoningPreference.INHERIT +) +ROUTE_REASONING_PREFERENCES = tuple(ReasoningPreference) diff --git a/src/free_claude_code/config/settings.py b/src/free_claude_code/config/settings.py index 6681c19d09e1fe4c4f589ced1d89dc33b574e01f..29f069f5de6382b8ca4408d5a7072c93f4c8023c 100644 --- a/src/free_claude_code/config/settings.py +++ b/src/free_claude_code/config/settings.py @@ -14,6 +14,7 @@ from .env_files import ( ) from .nim import NimSettings from .provider_catalog import SUPPORTED_PROVIDER_IDS +from .reasoning import ReasoningPreference class Settings(BaseSettings): @@ -167,20 +168,25 @@ class Settings(BaseSettings): provider_max_concurrency: int = Field( default=5, validation_alias="PROVIDER_MAX_CONCURRENCY" ) - enable_model_thinking: bool = Field( - default=True, validation_alias="ENABLE_MODEL_THINKING" + reasoning_policy: ReasoningPreference = Field( + default=ReasoningPreference.CLIENT, + validation_alias="REASONING_POLICY", ) - enable_fable_thinking: bool | None = Field( - default=None, validation_alias="ENABLE_FABLE_THINKING" + reasoning_fable: ReasoningPreference = Field( + default=ReasoningPreference.INHERIT, + validation_alias="REASONING_FABLE", ) - enable_opus_thinking: bool | None = Field( - default=None, validation_alias="ENABLE_OPUS_THINKING" + reasoning_opus: ReasoningPreference = Field( + default=ReasoningPreference.INHERIT, + validation_alias="REASONING_OPUS", ) - enable_sonnet_thinking: bool | None = Field( - default=None, validation_alias="ENABLE_SONNET_THINKING" + reasoning_sonnet: ReasoningPreference = Field( + default=ReasoningPreference.INHERIT, + validation_alias="REASONING_SONNET", ) - enable_haiku_thinking: bool | None = Field( - default=None, validation_alias="ENABLE_HAIKU_THINKING" + reasoning_haiku: ReasoningPreference = Field( + default=ReasoningPreference.INHERIT, + validation_alias="REASONING_HAIKU", ) # ==================== HTTP Client Timeouts ==================== @@ -301,10 +307,6 @@ class Settings(BaseSettings): "model_opus", "model_sonnet", "model_haiku", - "enable_fable_thinking", - "enable_opus_thinking", - "enable_sonnet_thinking", - "enable_haiku_thinking", mode="before", ) @classmethod @@ -329,6 +331,15 @@ class Settings(BaseSettings): raise ValueError(f"LOG_LEVEL must be one of {sorted(valid)}, got {v!r}") return upper + @field_validator("reasoning_policy") + @classmethod + def validate_root_reasoning_policy( + cls, value: ReasoningPreference + ) -> ReasoningPreference: + if value is ReasoningPreference.INHERIT: + raise ValueError("REASONING_POLICY cannot inherit") + return value + @field_validator("whisper_device") @classmethod def validate_whisper_device(cls, v: str) -> str: diff --git a/src/free_claude_code/core/__init__.py b/src/free_claude_code/core/__init__.py index 716557d1e0be136f9b8ca088a153d439f6c2f419..5005c849e013159cb894d9810fdaf73c4e27ad01 100644 --- a/src/free_claude_code/core/__init__.py +++ b/src/free_claude_code/core/__init__.py @@ -1 +1,15 @@ """Neutral shared application core.""" + +from .reasoning import ( + DEFAULT_REASONING_POLICY, + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) + +__all__ = [ + "DEFAULT_REASONING_POLICY", + "ReasoningControl", + "ReasoningEffort", + "ReasoningPolicy", +] diff --git a/src/free_claude_code/core/anthropic/request_snapshot.py b/src/free_claude_code/core/anthropic/request_snapshot.py index 75c1d5bb946d24a20c2bfcfa8e1ea38bac709c30..36bb85bb204c8b1fff80c2374a1940dc71c0b02f 100644 --- a/src/free_claude_code/core/anthropic/request_snapshot.py +++ b/src/free_claude_code/core/anthropic/request_snapshot.py @@ -28,7 +28,6 @@ def anthropic_request_snapshot( "stop_sequences", "metadata", "stream", - "thinking_enabled", ) if key in data and data[key] is not None } diff --git a/src/free_claude_code/core/gateway_model_ids.py b/src/free_claude_code/core/gateway_model_ids.py index c7db23e990d7af0674641b9b4f71db07f5cb7482..184c85a918dceec655343666bab1b76f025bce40 100644 --- a/src/free_claude_code/core/gateway_model_ids.py +++ b/src/free_claude_code/core/gateway_model_ids.py @@ -14,7 +14,7 @@ NO_THINKING_GATEWAY_MODEL_ID_PREFIX = "claude-3-freecc-no-thinking" class DecodedGatewayModelId: provider_id: str provider_model: str - force_thinking_enabled: bool | None = None + force_reasoning_off: bool = False def gateway_model_id(provider_model_ref: str) -> str: @@ -33,11 +33,10 @@ def decode_gateway_model_id(model_name: str) -> DecodedGatewayModelId | None: if not separator: return None - force_thinking_enabled: bool | None if prefix == GATEWAY_MODEL_ID_PREFIX: - force_thinking_enabled = None + force_reasoning_off = False elif prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: - force_thinking_enabled = False + force_reasoning_off = True else: return None @@ -48,5 +47,5 @@ def decode_gateway_model_id(model_name: str) -> DecodedGatewayModelId | None: return DecodedGatewayModelId( provider_id=provider_id, provider_model=provider_model, - force_thinking_enabled=force_thinking_enabled, + force_reasoning_off=force_reasoning_off, ) diff --git a/src/free_claude_code/core/openai_responses/input.py b/src/free_claude_code/core/openai_responses/input.py index 3fc2232ffc84d429716c4c996cc09e3078a3cb95..bd2b15cedd25fe15f816335036731f1b50be2747 100644 --- a/src/free_claude_code/core/openai_responses/input.py +++ b/src/free_claude_code/core/openai_responses/input.py @@ -10,7 +10,7 @@ from .models import OpenAIResponsesRequest from .reasoning import ( combine_reasoning, reasoning_text_from_item, - responses_reasoning_to_thinking, + responses_reasoning_to_output_config, ) from .tools import ( call_id_from_item, @@ -65,8 +65,8 @@ def convert_request_to_anthropic_payload( if request.metadata is not None: payload["metadata"] = request.metadata - if thinking := responses_reasoning_to_thinking(request.reasoning): - payload["thinking"] = thinking + if output_config := responses_reasoning_to_output_config(request.reasoning): + payload["output_config"] = output_config raw_tool_choice = request.tool_choice tools = convert_tools(request.tools) diff --git a/src/free_claude_code/core/openai_responses/reasoning.py b/src/free_claude_code/core/openai_responses/reasoning.py index 12a732fa89192349760b5724e332c5c09c39306b..022b9f1e9b93379eefa2ecb150bd75e432302169 100644 --- a/src/free_claude_code/core/openai_responses/reasoning.py +++ b/src/free_claude_code/core/openai_responses/reasoning.py @@ -32,13 +32,13 @@ def combine_reasoning(existing: str | None, addition: str | None) -> str | None: return f"{existing}\n{addition}" -def responses_reasoning_to_thinking(value: Any) -> dict[str, Any] | None: +def responses_reasoning_to_output_config(value: Any) -> dict[str, Any] | None: + """Preserve the client's named effort for application-level resolution.""" if not isinstance(value, Mapping): return None - if value.get("effort") == "none": - return {"type": "disabled", "enabled": False} - if any(item is not None for item in value.values()): - return {"type": "enabled", "enabled": True} + effort = value.get("effort") + if isinstance(effort, str) and effort.strip(): + return {"effort": effort.strip().lower()} return None diff --git a/src/free_claude_code/core/reasoning.py b/src/free_claude_code/core/reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..6e8dd6ae9a5909b9d115e1ef33d0a4e2edd938a2 --- /dev/null +++ b/src/free_claude_code/core/reasoning.py @@ -0,0 +1,93 @@ +"""Provider-neutral reasoning intent.""" + +from dataclasses import dataclass +from enum import StrEnum + + +class ReasoningControl(StrEnum): + """Whether a request explicitly controls reasoning computation.""" + + DEFAULT = "default" + OFF = "off" + ON = "on" + + +class ReasoningEffort(StrEnum): + """Named reasoning effort understood at the FCC application boundary.""" + + MINIMAL = "minimal" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + XHIGH = "xhigh" + MAX = "max" + + +@dataclass(frozen=True, slots=True) +class ReasoningPolicy: + """Resolved client and configuration intent passed to one provider. + + ``control`` and ``effort`` remain independent because clients may set an + overall effort while separately disabling extended thinking. Providers + translate the representable subset without changing the original intent. + """ + + control: ReasoningControl = ReasoningControl.DEFAULT + effort: ReasoningEffort | None = None + budget_tokens: int | None = None + + def __post_init__(self) -> None: + if self.budget_tokens is not None and ( + not isinstance(self.budget_tokens, int) + or isinstance(self.budget_tokens, bool) + or self.budget_tokens <= 0 + ): + raise ValueError("Reasoning budget must be a positive integer.") + if self.budget_tokens is not None and self.control is not ReasoningControl.ON: + raise ValueError("A reasoning budget requires reasoning control to be on.") + + @classmethod + def provider_default(cls) -> ReasoningPolicy: + """Leave reasoning computation to the provider.""" + + return cls() + + @classmethod + def off(cls) -> ReasoningPolicy: + """Explicitly disable reasoning computation and output.""" + + return cls(control=ReasoningControl.OFF) + + @classmethod + def on( + cls, + *, + effort: ReasoningEffort | None = None, + budget_tokens: int | None = None, + ) -> ReasoningPolicy: + """Explicitly enable reasoning with optional client controls.""" + + return cls( + control=ReasoningControl.ON, + effort=effort, + budget_tokens=budget_tokens, + ) + + @property + def output_enabled(self) -> bool: + """Return whether provider reasoning may be exposed to the client.""" + + return self.control is not ReasoningControl.OFF + + @property + def requests_reasoning(self) -> bool: + """Return whether the request explicitly asks the provider to reason.""" + + return self.control is not ReasoningControl.OFF and ( + self.control is ReasoningControl.ON + or self.effort is not None + or self.budget_tokens is not None + ) + + +DEFAULT_REASONING_POLICY = ReasoningPolicy.provider_default() diff --git a/src/free_claude_code/providers/base.py b/src/free_claude_code/providers/base.py index a7dd4b94f2072c35daefd0f42d9224868c54ceb4..14a8de4c57d6eefa8ab2192f9728a070391d5134 100644 --- a/src/free_claude_code/providers/base.py +++ b/src/free_claude_code/providers/base.py @@ -13,6 +13,7 @@ from free_claude_code.core.diagnostics import ( exception_cause_types, redacted_exception_traceback, ) +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.core.trace import trace_event from free_claude_code.providers.model_listing import model_infos_from_ids @@ -33,7 +34,6 @@ class ProviderConfig: http_read_timeout: float = 300.0 http_write_timeout: float = 10.0 http_connect_timeout: float = HTTP_CONNECT_TIMEOUT_DEFAULT - enable_thinking: bool = True proxy: str = "" log_raw_sse_events: bool = False log_api_error_tracebacks: bool = False @@ -45,27 +45,12 @@ class BaseProvider(ABC): def __init__(self, config: ProviderConfig): self._config = config - def _is_thinking_enabled( - self, request: MessagesRequest, thinking_enabled: bool | None = None - ) -> bool: - """Return whether thinking should be enabled for this request.""" - thinking = request.thinking - config_enabled = ( - self._config.enable_thinking - if thinking_enabled is None - else thinking_enabled - ) - request_enabled = True - if thinking is not None: - if "enabled" in thinking.model_fields_set and thinking.enabled is not None: - request_enabled = thinking.enabled - if thinking.type == "disabled": - request_enabled = False - return config_enabled and request_enabled - @abstractmethod def preflight_stream( - self, request: MessagesRequest, *, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> None: """Validate the upstream request before opening an SSE stream.""" @@ -131,6 +116,6 @@ class BaseProvider(ABC): input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> AsyncIterator[str]: """Stream response in Anthropic SSE format.""" diff --git a/src/free_claude_code/providers/cloudflare/client.py b/src/free_claude_code/providers/cloudflare/client.py index d37920d9c3d89cecf8f45c36e9b70e8348664b29..250f0cd2b5e834ce6e53bdaddcaf3e0861dea6fe 100644 --- a/src/free_claude_code/providers/cloudflare/client.py +++ b/src/free_claude_code/providers/cloudflare/client.py @@ -10,7 +10,7 @@ import httpx from free_claude_code.application.errors import ApplicationUnavailableError from free_claude_code.application.model_metadata import ProviderModelInfo from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT -from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.http import maybe_await_aclose from free_claude_code.providers.model_listing import ( @@ -19,19 +19,22 @@ from free_claude_code.providers.model_listing import ( model_infos_from_ids, ) from free_claude_code.providers.openai_chat import ( + ChatTemplateReasoning, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, - build_openai_chat_request_body, + validate_extra_body_does_not_override_canonical_fields, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter _REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="CLOUDFLARE", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, max_tokens_field="max_completion_tokens", ) -_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY, ChatTemplateReasoning()) def cloudflare_ai_base_url(api_root: str | None, account_id: str) -> str: @@ -118,22 +121,12 @@ class CloudflareProvider(OpenAIChatProvider): finally: await maybe_await_aclose(response) - def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None - ) -> dict: - return build_openai_chat_request_body( - request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), - policy=_REQUEST_POLICY, - postprocessors=(_apply_cloudflare_request_quirks,), - ) - def _handle_extra_reasoning( - self, delta: Any, ledger: Any, *, thinking_enabled: bool + self, delta: Any, ledger: Any, *, output_reasoning: bool ) -> Iterator[str]: """Map Cloudflare's ``reasoning`` delta field to Anthropic thinking.""" reasoning = _cloudflare_reasoning(delta) - if not thinking_enabled or not reasoning: + if not output_reasoning or not reasoning: return yield from ledger.ensure_thinking_block() yield ledger.emit_thinking_delta(reasoning) @@ -142,18 +135,6 @@ class CloudflareProvider(OpenAIChatProvider): return {"Authorization": f"Bearer {self._api_key}"} -def _apply_cloudflare_request_quirks( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - """Attach Cloudflare Workers AI chat-template thinking control.""" - extra_body = body.setdefault("extra_body", {}) - if not isinstance(extra_body, dict): - return - chat_template_kwargs = extra_body.setdefault("chat_template_kwargs", {}) - if isinstance(chat_template_kwargs, dict): - chat_template_kwargs.setdefault("thinking", thinking_enabled) - - def _cloudflare_reasoning(delta: Any) -> str | None: reasoning = getattr(delta, "reasoning", None) if isinstance(reasoning, str) and reasoning: diff --git a/src/free_claude_code/providers/deepseek/client.py b/src/free_claude_code/providers/deepseek/client.py index dfaa211ba3949487c370af5535625b73035105b0..07bfb0b740a86b7489c0b7be91bf7e747ce88524 100644 --- a/src/free_claude_code/providers/deepseek/client.py +++ b/src/free_claude_code/providers/deepseek/client.py @@ -3,18 +3,22 @@ from typing import Any from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( + NO_REASONING, OpenAIChatProfile, OpenAIChatProvider, - OpenAIChatRequestPolicy, usage_int, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter -from .compat import build_deepseek_request_body +from .compat import DEEPSEEK_REQUEST_POLICY, build_deepseek_request_body -_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="DEEPSEEK")) +_PROFILE = OpenAIChatProfile( + DEEPSEEK_REQUEST_POLICY, + NO_REASONING, +) class DeepSeekProvider(OpenAIChatProvider): @@ -28,11 +32,14 @@ class DeepSeekProvider(OpenAIChatProvider): ) def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: return build_deepseek_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + reasoning=reasoning, ) def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]: diff --git a/src/free_claude_code/providers/deepseek/compat.py b/src/free_claude_code/providers/deepseek/compat.py index b0b28c08c43d1fc0b9411170c0c86729b2f4fd3f..d941c06decc03fcbb78efc2d7f53aa357b34338e 100644 --- a/src/free_claude_code/providers/deepseek/compat.py +++ b/src/free_claude_code/providers/deepseek/compat.py @@ -8,17 +8,24 @@ from loguru import logger from free_claude_code.application.errors import InvalidRequestError from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from free_claude_code.core.anthropic import ( + ReasoningReplayMode, dump_messages_request, serialize_tool_result_content, ) from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ( + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) from free_claude_code.providers.openai_chat import ( OpenAIChatRequestPolicy, build_openai_chat_request_body, ) -_REQUEST_POLICY = OpenAIChatRequestPolicy( +DEEPSEEK_REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="DEEPSEEK", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, include_extra_body=True, ) @@ -39,7 +46,7 @@ _OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT} def build_deepseek_request_body( - request_data: MessagesRequest, *, thinking_enabled: bool + request_data: MessagesRequest, *, reasoning: ReasoningPolicy ) -> dict: """Build a DeepSeek Chat Completions body from an Anthropic request.""" logger.debug( @@ -57,8 +64,8 @@ def build_deepseek_request_body( has_tool_history = _has_tool_history(data) has_replayable_tool_thinking = _all_tool_calls_have_replayable_thinking(data) unsafe_tool_followup = has_tool_history and not has_replayable_tool_thinking - effective_thinking_enabled = thinking_enabled and not unsafe_tool_followup - if thinking_enabled: + effective_reasoning = reasoning + if reasoning.control is not ReasoningControl.OFF: if unsafe_tool_followup: logger.debug( "DEEPSEEK_REQUEST: disabling thinking for tool follow-up without " @@ -68,6 +75,7 @@ def build_deepseek_request_body( len(data.get("tools", [])), ) _remove_deepseek_thinking_hints(data) + effective_reasoning = ReasoningPolicy.off() elif has_tool_history: logger.debug( "DEEPSEEK_REQUEST: keeping thinking for tool follow-up with " @@ -93,9 +101,8 @@ def build_deepseek_request_body( sanitized_request = MessagesRequest.model_validate(data) body = build_openai_chat_request_body( sanitized_request, - thinking_enabled=effective_thinking_enabled, - reasoning_history_enabled=True, - policy=_REQUEST_POLICY, + reasoning=effective_reasoning, + policy=DEEPSEEK_REQUEST_POLICY, postprocessors=(_apply_deepseek_chat_extras,), ) if "max_tokens" not in body or body.get("max_tokens") is None: @@ -427,10 +434,17 @@ def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None: def _apply_deepseek_chat_extras( - body: dict[str, Any], _request_data: MessagesRequest, thinking_enabled: bool + body: dict[str, Any], _request_data: MessagesRequest, policy: ReasoningPolicy ) -> None: - if not thinking_enabled or body.get("model") == "deepseek-reasoner": - return extra_body = body.setdefault("extra_body", {}) - if isinstance(extra_body, dict): - extra_body.setdefault("thinking", {"type": "enabled"}) + if not isinstance(extra_body, dict): + return + if policy.control is ReasoningControl.OFF: + extra_body["thinking"] = {"type": "disabled"} + return + if policy.effort in {ReasoningEffort.XHIGH, ReasoningEffort.MAX}: + body["reasoning_effort"] = "max" + elif policy.effort is not None: + body["reasoning_effort"] = "high" + elif policy.requests_reasoning: + extra_body["thinking"] = {"type": "enabled"} diff --git a/src/free_claude_code/providers/gemini/client.py b/src/free_claude_code/providers/gemini/client.py index b5874e9164510c126d74ed2f97ff068b3b526fae..cfcbd4e1bbb07fa07aa5b36de3aebf7aeae1c82d 100644 --- a/src/free_claude_code/providers/gemini/client.py +++ b/src/free_claude_code/providers/gemini/client.py @@ -3,9 +3,16 @@ from copy import deepcopy from typing import Any +from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ( + DEFAULT_REASONING_POLICY, + ReasoningEffort, + ReasoningPolicy, +) from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( + NamedEffortReasoning, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, @@ -16,8 +23,24 @@ from free_claude_code.providers.rate_limit import ProviderRateLimiter from .quirks import apply_gemini_request_quirks _MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096 -_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="GEMINI") -_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="GEMINI", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, +) +_PROFILE = OpenAIChatProfile( + _REQUEST_POLICY, + NamedEffortReasoning( + ( + (ReasoningEffort.MINIMAL, "minimal"), + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.MEDIUM, "medium"), + (ReasoningEffort.HIGH, "high"), + (ReasoningEffort.XHIGH, "high"), + (ReasoningEffort.MAX, "high"), + ), + disabled_value="none", + ), +) class GeminiProvider(OpenAIChatProvider): @@ -45,18 +68,22 @@ class GeminiProvider(OpenAIChatProvider): self._tool_call_extra_content_by_id[tool_call_id] = deepcopy(extra_content) def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: return build_openai_chat_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + reasoning=reasoning, policy=_REQUEST_POLICY, postprocessors=( - lambda body, request_data, enabled: apply_gemini_request_quirks( + lambda body, request_data, policy: apply_gemini_request_quirks( body, request_data, - enabled, + policy, tool_call_extra_content_by_id=self._tool_call_extra_content_by_id, ), + _PROFILE.apply_reasoning, ), ) diff --git a/src/free_claude_code/providers/gemini/quirks.py b/src/free_claude_code/providers/gemini/quirks.py index ca5c16b7f649d2b718ff40e9c260a410dd700203..f37b3310ad5b037041da68f586b2a3a56d512bea 100644 --- a/src/free_claude_code/providers/gemini/quirks.py +++ b/src/free_claude_code/providers/gemini/quirks.py @@ -4,6 +4,7 @@ from copy import deepcopy from typing import Any, cast from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ReasoningPolicy GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" @@ -11,7 +12,7 @@ GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" def apply_gemini_request_quirks( body: dict[str, Any], request_data: MessagesRequest, - thinking_enabled: bool, + reasoning: ReasoningPolicy, *, tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None, ) -> None: @@ -21,10 +22,8 @@ def apply_gemini_request_quirks( if isinstance(request_extra, dict): extra_body.update(deepcopy(request_extra)) - if thinking_enabled: + if reasoning.requests_reasoning: _apply_thinking_config(extra_body) - else: - body["reasoning_effort"] = "none" if extra_body: body["extra_body"] = extra_body @@ -53,10 +52,6 @@ def _apply_thinking_config(extra_body: dict[str, Any]) -> None: thinking_cfg.setdefault("include_thoughts", True) -def _is_gemini_3_model(model: Any) -> bool: - return "gemini-3" in str(model).lower() - - def _thought_signature_from_extra_content(extra_content: Any) -> str | None: if not isinstance(extra_content, dict): return None @@ -136,12 +131,7 @@ def _apply_cached_tool_call_signatures( tool_call["extra_content"] = deepcopy(cached_extra_content) -def _apply_gemini_3_missing_current_turn_signatures( - body: dict[str, Any], messages: list[Any] -) -> None: - if not _is_gemini_3_model(body.get("model")): - return - +def _apply_missing_current_turn_signatures(messages: list[Any]) -> None: start_index = _current_turn_start_index(messages) for message in messages[start_index + 1 :]: if not isinstance(message, dict) or message.get("role") != "assistant": @@ -168,4 +158,4 @@ def _apply_gemini_tool_call_signatures( if not isinstance(messages, list): return _apply_cached_tool_call_signatures(messages, tool_call_extra_content_by_id or {}) - _apply_gemini_3_missing_current_turn_signatures(body, messages) + _apply_missing_current_turn_signatures(messages) diff --git a/src/free_claude_code/providers/github_models/client.py b/src/free_claude_code/providers/github_models/client.py index d7aab56ee8baad707496f47f1a29122bc4efda1d..4cd88c3c36847a5482fa25dac6c647c76cf270b6 100644 --- a/src/free_claude_code/providers/github_models/client.py +++ b/src/free_claude_code/providers/github_models/client.py @@ -6,7 +6,7 @@ from typing import Any import httpx from free_claude_code.application.model_metadata import ProviderModelInfo -from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.http import maybe_await_aclose from free_claude_code.providers.model_listing import ( @@ -14,10 +14,10 @@ from free_claude_code.providers.model_listing import ( model_infos_from_ids, ) from free_claude_code.providers.openai_chat import ( + NO_REASONING, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, - build_openai_chat_request_body, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter @@ -26,8 +26,9 @@ GITHUB_MODELS_API_VERSION = "2026-03-10" _REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="GITHUB_MODELS", + reasoning_replay=ReasoningReplayMode.THINK_TAGS, ) -_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY, NO_REASONING) _REQUIRED_MODEL_CAPABILITIES = frozenset({"streaming", "tool-calling"}) @@ -81,15 +82,6 @@ class GitHubModelsProvider(OpenAIChatProvider): finally: await maybe_await_aclose(response) - def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None - ) -> dict: - return build_openai_chat_request_body( - request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), - policy=_REQUEST_POLICY, - ) - def _model_list_headers(self) -> dict[str, str]: return _github_models_api_headers(self._api_key) diff --git a/src/free_claude_code/providers/lmstudio/client.py b/src/free_claude_code/providers/lmstudio/client.py index 84f3a2fcf578b9825643c1d04392271a2bf57aee..fcd4d9a6a2876e6d1e98303cc2e1552726580f87 100644 --- a/src/free_claude_code/providers/lmstudio/client.py +++ b/src/free_claude_code/providers/lmstudio/client.py @@ -17,22 +17,41 @@ import httpx from loguru import logger from free_claude_code.application.errors import InvalidRequestError -from free_claude_code.core.anthropic import ( - ReasoningReplayMode, - build_base_request_body, - get_token_count, -) -from free_claude_code.core.anthropic.conversion import OpenAIConversionError +from free_claude_code.core.anthropic import ReasoningReplayMode, get_token_count from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ( + DEFAULT_REASONING_POLICY, + ReasoningEffort, + ReasoningPolicy, +) from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( + NamedEffortReasoning, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter -_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="LMSTUDIO")) +_PROFILE = OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="LMSTUDIO", + reasoning_replay=ReasoningReplayMode.DISABLED, + ), + NamedEffortReasoning( + ( + (ReasoningEffort.MINIMAL, "low"), + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.MEDIUM, "medium"), + (ReasoningEffort.HIGH, "high"), + (ReasoningEffort.XHIGH, "high"), + (ReasoningEffort.MAX, "high"), + ), + disabled_value="none", + enabled_value="high", + budget_field="reasoning_tokens", + ), +) class LMStudioProvider(OpenAIChatProvider): @@ -53,28 +72,13 @@ class LMStudioProvider(OpenAIChatProvider): ) self._loaded_context_cache: tuple[float, int | None] = (0.0, None) - def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None - ) -> dict: - """Build an OpenAI chat body from the Anthropic request. - - Prior-turn thinking is never replayed: Mistral-family templates have - no assistant reasoning field, and replaying ```` text inflates - the local context for no benefit. New-response thinking still streams - back via ``reasoning_content``/```` parsing in the provider. - """ - try: - return build_base_request_body( - request, - reasoning_replay=ReasoningReplayMode.DISABLED, - ) - except OpenAIConversionError as exc: - raise InvalidRequestError(str(exc)) from exc - def preflight_stream( - self, request: MessagesRequest, *, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> None: - super().preflight_stream(request, thinking_enabled=thinking_enabled) + super().preflight_stream(request, reasoning=reasoning) self._preflight_context_budget(request) def _preflight_context_budget(self, request: MessagesRequest) -> None: diff --git a/src/free_claude_code/providers/mistral/client.py b/src/free_claude_code/providers/mistral/client.py index 90401109968d8724bd817b662f15d2de62b84722..fb8723037f1032e224bb18e159785a0590633cce 100644 --- a/src/free_claude_code/providers/mistral/client.py +++ b/src/free_claude_code/providers/mistral/client.py @@ -4,9 +4,12 @@ from typing import Any from loguru import logger +from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( + NO_REASONING, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, @@ -21,8 +24,11 @@ from .reasoning import ( normalize_mistral_stream, ) -_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="MISTRAL") -_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="MISTRAL", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY, NO_REASONING) class MistralProvider(OpenAIChatProvider): @@ -36,19 +42,17 @@ class MistralProvider(OpenAIChatProvider): ) def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: - effective_thinking_enabled = self._is_thinking_enabled( - request, thinking_enabled - ) body = build_openai_chat_request_body( request, - thinking_enabled=effective_thinking_enabled, + reasoning=reasoning, policy=_REQUEST_POLICY, ) - apply_mistral_reasoning_request_shape( - body, thinking_enabled=effective_thinking_enabled - ) + apply_mistral_reasoning_request_shape(body, reasoning=reasoning) return body def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: diff --git a/src/free_claude_code/providers/mistral/reasoning.py b/src/free_claude_code/providers/mistral/reasoning.py index 9b62d1c9a3004bdd8320facd0aa62c413fd41628..f85ab9d0faeca42b848452f75bd299cb967bb6a6 100644 --- a/src/free_claude_code/providers/mistral/reasoning.py +++ b/src/free_claude_code/providers/mistral/reasoning.py @@ -8,6 +8,7 @@ from typing import Any import openai +from free_claude_code.core.reasoning import ReasoningControl, ReasoningPolicy from free_claude_code.providers.http import maybe_await_aclose MISTRAL_REASONING_EFFORT = "high" @@ -23,10 +24,12 @@ _REJECTION_WORDS = ("unsupported", "unknown", "invalid", "forbidden", "extra") def apply_mistral_reasoning_request_shape( - body: dict[str, Any], *, thinking_enabled: bool + body: dict[str, Any], *, reasoning: ReasoningPolicy ) -> None: """Apply Mistral's native reasoning request shape in-place.""" - if thinking_enabled: + if reasoning.control is ReasoningControl.OFF: + body["reasoning_effort"] = "none" + elif reasoning.requests_reasoning: body["reasoning_effort"] = MISTRAL_REASONING_EFFORT else: body.pop("reasoning_effort", None) @@ -38,13 +41,11 @@ def apply_mistral_reasoning_request_shape( for message in messages: if not isinstance(message, dict) or message.get("role") != "assistant": continue - reasoning = _clean_text(message.pop("reasoning_content", None)) - if thinking_enabled and reasoning: + replayed_reasoning = _clean_text(message.pop("reasoning_content", None)) + if replayed_reasoning: message["content"] = _content_with_prepended_thinking( - message.get("content"), reasoning + message.get("content"), replayed_reasoning ) - elif not thinking_enabled: - message["content"] = _content_without_thinking(message.get("content")) def clone_body_without_mistral_reasoning( @@ -178,11 +179,6 @@ def _content_with_prepended_thinking( return chunks -def _content_without_thinking(content: Any) -> Any: - stripped, _ = _strip_mistral_thinking_content(content) - return stripped - - def _strip_mistral_thinking_content(content: Any) -> tuple[Any, bool]: if not _is_sequence(content): return content, False diff --git a/src/free_claude_code/providers/nvidia_nim/client.py b/src/free_claude_code/providers/nvidia_nim/client.py index 231f9ff06e88b31a35d4e2ff6dd723ccc91c340e..10ca669ffa6b2d5457b74827e957b04524442c5f 100644 --- a/src/free_claude_code/providers/nvidia_nim/client.py +++ b/src/free_claude_code/providers/nvidia_nim/client.py @@ -10,18 +10,19 @@ from loguru import logger from free_claude_code.config.nim import NimSettings from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.failure_policy import ( overloaded_provider_failure, ) from free_claude_code.providers.openai_chat import ( + NO_REASONING, OpenAIChatProfile, OpenAIChatProvider, - OpenAIChatRequestPolicy, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter -from .request_options import build_nim_request_body +from .request_options import NIM_REQUEST_POLICY, build_nim_request_body from .retry import ( clone_body_without_chat_template, clone_body_without_reasoning_budget, @@ -33,7 +34,10 @@ from .tool_schema import ( ) _DEGRADED_FUNCTION_STATE = "degraded function cannot be invoked" -_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="NIM")) +_PROFILE = OpenAIChatProfile( + NIM_REQUEST_POLICY, + NO_REASONING, +) class NvidiaNimProvider(OpenAIChatProvider): @@ -54,13 +58,16 @@ class NvidiaNimProvider(OpenAIChatProvider): self._nim_settings = nim_settings def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: """Internal helper for tests and shared building.""" return build_nim_request_body( request, self._nim_settings, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + reasoning=reasoning, ) def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]: diff --git a/src/free_claude_code/providers/nvidia_nim/request_options.py b/src/free_claude_code/providers/nvidia_nim/request_options.py index 7c88a1afdba3855ef00d9fe3c585fe5d84fe84ec..f218916a063fc5ffa8e63c5355e60464998094cc 100644 --- a/src/free_claude_code/providers/nvidia_nim/request_options.py +++ b/src/free_claude_code/providers/nvidia_nim/request_options.py @@ -1,10 +1,12 @@ """NVIDIA NIM request option injection.""" +from copy import deepcopy from typing import Any from free_claude_code.config.nim import NimSettings -from free_claude_code.core.anthropic import set_if_not_none +from free_claude_code.core.anthropic import ReasoningReplayMode, set_if_not_none from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ReasoningControl, ReasoningPolicy from free_claude_code.providers.openai_chat import ( OpenAIChatRequestPolicy, build_openai_chat_request_body, @@ -12,22 +14,25 @@ from free_claude_code.providers.openai_chat import ( from .tool_schema import sanitize_nim_tool_schemas -_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="NIM") +NIM_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="NIM", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, +) def build_nim_request_body( - request_data: MessagesRequest, nim: NimSettings, *, thinking_enabled: bool + request_data: MessagesRequest, nim: NimSettings, *, reasoning: ReasoningPolicy ) -> dict[str, Any]: """Build OpenAI-format request body from Anthropic request plus NIM settings.""" return build_openai_chat_request_body( request_data, - thinking_enabled=thinking_enabled, - policy=_REQUEST_POLICY, + reasoning=reasoning, + policy=NIM_REQUEST_POLICY, postprocessors=( - lambda body, request, enabled: apply_nim_request_options( + lambda body, request, policy: apply_nim_request_options( body, request, - enabled, + policy, nim=nim, ), ), @@ -37,7 +42,7 @@ def build_nim_request_body( def apply_nim_request_options( body: dict[str, Any], request_data: MessagesRequest, - thinking_enabled: bool, + reasoning: ReasoningPolicy, *, nim: NimSettings, ) -> None: @@ -71,14 +76,30 @@ def apply_nim_request_options( extra_body: dict[str, Any] = {} request_extra = request_data.extra_body if request_extra: - extra_body.update(request_extra) - - if thinking_enabled: - chat_template_kwargs = extra_body.setdefault( - "chat_template_kwargs", {"thinking": True, "enable_thinking": True} - ) + extra_body.update(deepcopy(request_extra)) + for key in ( + "reasoning", + "reasoning_effort", + "reasoning_tokens", + "thinking", + "thinking_budget_tokens", + ): + extra_body.pop(key, None) + request_template_kwargs = extra_body.get("chat_template_kwargs") + if isinstance(request_template_kwargs, dict): + for key in ("thinking", "enable_thinking", "reasoning_budget"): + request_template_kwargs.pop(key, None) + if not request_template_kwargs: + extra_body.pop("chat_template_kwargs", None) + + if reasoning.control is ReasoningControl.OFF or reasoning.requests_reasoning: + chat_template_kwargs = extra_body.setdefault("chat_template_kwargs", {}) if isinstance(chat_template_kwargs, dict): - chat_template_kwargs.setdefault("reasoning_budget", max_tokens) + enabled = reasoning.control is not ReasoningControl.OFF + chat_template_kwargs["thinking"] = enabled + chat_template_kwargs["enable_thinking"] = enabled + if enabled and reasoning.budget_tokens is not None: + chat_template_kwargs["reasoning_budget"] = reasoning.budget_tokens req_top_k = request_data.top_k top_k = req_top_k if req_top_k is not None else nim.top_k diff --git a/src/free_claude_code/providers/open_router/client.py b/src/free_claude_code/providers/open_router/client.py index d45abfdf6c73e6ece4bcb8c1603fad7484a59e1b..311dd557d1405745e0d8b3f80b32f336c829983b 100644 --- a/src/free_claude_code/providers/open_router/client.py +++ b/src/free_claude_code/providers/open_router/client.py @@ -6,8 +6,10 @@ from typing import Any from free_claude_code.application.model_metadata import ProviderModelInfo from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -from free_claude_code.core.anthropic.models import MessagesRequest, ThinkingConfig +from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.core.anthropic.streaming import AnthropicStreamLedger +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.model_listing import ( extract_openrouter_tool_model_ids, @@ -17,18 +19,18 @@ from free_claude_code.providers.openai_chat import ( OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, - build_openai_chat_request_body, + ReasoningObject, validate_extra_body_does_not_override_canonical_fields, ) from free_claude_code.providers.rate_limit import ProviderRateLimiter _REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="OPENROUTER", + reasoning_replay=ReasoningReplayMode.REASONING_CONTENT, include_extra_body=True, extra_body_validator=validate_extra_body_does_not_override_canonical_fields, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ) -_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) class OpenRouterProvider(OpenAIChatProvider): @@ -41,22 +43,6 @@ class OpenRouterProvider(OpenAIChatProvider): rate_limiter=rate_limiter, ) - def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None - ) -> dict: - effective_thinking_enabled = self._is_thinking_enabled( - request, thinking_enabled - ) - return build_openai_chat_request_body( - request, - thinking_enabled=effective_thinking_enabled, - policy=_REQUEST_POLICY, - postprocessors=( - _apply_openrouter_reasoning_policy, - _apply_openrouter_reasoning_details_replay, - ), - ) - async def list_model_ids(self) -> frozenset[str]: """Only advertise OpenRouter models that can run Claude Code tools.""" payload = await self._client.models.list() @@ -72,36 +58,17 @@ class OpenRouterProvider(OpenAIChatProvider): ) def _handle_extra_reasoning( - self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + self, delta: Any, ledger: AnthropicStreamLedger, *, output_reasoning: bool ) -> Iterator[str]: """Map OpenRouter reasoning details onto Anthropic thinking blocks.""" - if not thinking_enabled: + if not output_reasoning: return iter(()) return _iter_openrouter_reasoning_detail_events(delta, ledger) -def _apply_openrouter_reasoning_policy( - body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool -) -> None: - if not thinking_enabled: - return - extra_body = body.setdefault("extra_body", {}) - if not isinstance(extra_body, dict): - return - reasoning = extra_body.setdefault("reasoning", {"enabled": True}) - if not isinstance(reasoning, dict): - return - reasoning.setdefault("enabled", True) - budget_tokens = _thinking_budget_tokens(request.thinking) - if isinstance(budget_tokens, int): - reasoning.setdefault("max_tokens", budget_tokens) - - def _apply_openrouter_reasoning_details_replay( - body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool + body: dict[str, Any], request: MessagesRequest, _policy: ReasoningPolicy ) -> None: - if not thinking_enabled: - return assistant_details = _assistant_reasoning_details(request.messages) if not assistant_details: return @@ -124,6 +91,13 @@ def _apply_openrouter_reasoning_details_replay( break +_PROFILE = OpenAIChatProfile( + _REQUEST_POLICY, + ReasoningObject(tuple((effort, effort.value) for effort in ReasoningEffort)), + postprocessors=(_apply_openrouter_reasoning_details_replay,), +) + + def _assistant_reasoning_details(messages: Any) -> list[list[dict[str, Any]]]: if not _is_sequence(messages): return [] @@ -157,11 +131,6 @@ def _redacted_reasoning_details(content: Any) -> list[dict[str, Any]]: return details -def _thinking_budget_tokens(thinking: ThinkingConfig | None) -> int | None: - value = thinking.budget_tokens if thinking is not None else None - return value if isinstance(value, int) and not isinstance(value, bool) else None - - def _iter_openrouter_reasoning_detail_events( delta: Any, ledger: AnthropicStreamLedger ) -> Iterator[str]: diff --git a/src/free_claude_code/providers/openai_chat/__init__.py b/src/free_claude_code/providers/openai_chat/__init__.py index fa3ece4ebdfbdd1aeda90e1df5adf9cd1be8dcf0..317aee78a3683ddc549104ab4dd627e1503d8e3d 100644 --- a/src/free_claude_code/providers/openai_chat/__init__.py +++ b/src/free_claude_code/providers/openai_chat/__init__.py @@ -7,6 +7,12 @@ from .base_url import openai_v1_base_url from .extra_body import validate_extra_body_does_not_override_canonical_fields from .profiles import OPENAI_CHAT_PROFILES, OpenAIChatProfile from .provider import OpenAIChatProvider +from .reasoning import ( + NO_REASONING, + ChatTemplateReasoning, + NamedEffortReasoning, + ReasoningObject, +) from .request_policy import OpenAIChatRequestPolicy, build_openai_chat_request_body from .usage import usage_int @@ -28,10 +34,14 @@ def create_openai_chat_provider( __all__ = [ + "NO_REASONING", "OPENAI_CHAT_PROFILES", + "ChatTemplateReasoning", + "NamedEffortReasoning", "OpenAIChatProfile", "OpenAIChatProvider", "OpenAIChatRequestPolicy", + "ReasoningObject", "build_openai_chat_request_body", "create_openai_chat_provider", "openai_v1_base_url", diff --git a/src/free_claude_code/providers/openai_chat/extra_body.py b/src/free_claude_code/providers/openai_chat/extra_body.py index 7f08b1f7020dce03b4e581e0987fbac3eab93123..395ebf30bf2dfd54c600143cedd34c8d7a74fa0a 100644 --- a/src/free_claude_code/providers/openai_chat/extra_body.py +++ b/src/free_claude_code/providers/openai_chat/extra_body.py @@ -17,6 +17,21 @@ CANONICAL_OPENAI_CHAT_BODY_KEYS = frozenset( "stop", "stop_sequences", "stream_options", + "reasoning", + "reasoning_effort", + "reasoning_tokens", + "thinking", + "thinking_budget_tokens", + } +) + +REASONING_OPENAI_CHAT_BODY_KEYS = frozenset( + { + "reasoning", + "reasoning_effort", + "reasoning_tokens", + "thinking", + "thinking_budget_tokens", } ) @@ -30,3 +45,15 @@ def validate_extra_body_does_not_override_canonical_fields( raise ValueError( f"extra_body must not override canonical request fields: {sorted(bad)}" ) + + +def validate_extra_body_does_not_override_reasoning_fields( + extra: dict[str, Any], +) -> None: + """Keep provider reasoning translation authoritative over caller extras.""" + + bad = REASONING_OPENAI_CHAT_BODY_KEYS & extra.keys() + if bad: + raise ValueError( + f"extra_body must not override reasoning fields: {sorted(bad)}" + ) diff --git a/src/free_claude_code/providers/openai_chat/profiles.py b/src/free_claude_code/providers/openai_chat/profiles.py index 3900fb85d06fcccd18e9d71ce4ba2093ddeeab5d..6afeb61ef0a2507196574d003757c9939649ee4c 100644 --- a/src/free_claude_code/providers/openai_chat/profiles.py +++ b/src/free_claude_code/providers/openai_chat/profiles.py @@ -1,4 +1,4 @@ -"""Declarative profiles for providers with no adapter-specific runtime behavior.""" +"""Declarative profiles for ordinary OpenAI-compatible providers.""" from collections.abc import Mapping from copy import deepcopy @@ -9,17 +9,49 @@ from free_claude_code.application.errors import InvalidRequestError from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from .base_url import openai_v1_base_url -from .extra_body import validate_extra_body_does_not_override_canonical_fields +from .extra_body import ( + validate_extra_body_does_not_override_canonical_fields, + validate_extra_body_does_not_override_reasoning_fields, +) +from .reasoning import ( + LLAMACPP_REASONING, + NO_REASONING, + SPLIT_REASONING_OUTPUT, + NamedEffortReasoning, + ReasoningEncoder, + ReasoningObject, + ThinkingObjectReasoning, +) from .request_policy import OpenAIChatPostprocessor, OpenAIChatRequestPolicy +_ALL_EFFORTS = tuple((effort, effort.value) for effort in ReasoningEffort) +_LOW_MEDIUM_HIGH = ( + (ReasoningEffort.MINIMAL, "low"), + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.MEDIUM, "medium"), + (ReasoningEffort.HIGH, "high"), + (ReasoningEffort.XHIGH, "high"), + (ReasoningEffort.MAX, "high"), +) +_LOW_TO_MAX = ( + (ReasoningEffort.MINIMAL, "low"), + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.MEDIUM, "medium"), + (ReasoningEffort.HIGH, "high"), + (ReasoningEffort.XHIGH, "max"), + (ReasoningEffort.MAX, "max"), +) + @dataclass(frozen=True, slots=True) class OpenAIChatProfile: - """Immutable behavior differences for one ordinary OpenAI-chat provider.""" + """Immutable transport and reasoning behavior for one provider.""" request_policy: OpenAIChatRequestPolicy + reasoning: ReasoningEncoder postprocessors: tuple[OpenAIChatPostprocessor, ...] = () normalize_base_url: bool = False reasoning_delta_field: Literal["reasoning_content", "reasoning"] = ( @@ -37,12 +69,23 @@ class OpenAIChatProfile: value = getattr(delta, self.reasoning_delta_field, None) return value if isinstance(value, str) else None + def apply_reasoning( + self, + body: dict[str, Any], + _request: MessagesRequest, + policy: ReasoningPolicy, + ) -> None: + self.reasoning.encode(body, policy) + + @property + def request_postprocessors(self) -> tuple[OpenAIChatPostprocessor, ...]: + return (*self.postprocessors, self.apply_reasoning) + def _apply_cohere_request_quirks( - body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool + body: dict[str, Any], request: MessagesRequest, _policy: ReasoningPolicy ) -> None: _merge_allowed_cohere_extra_body(body, request.extra_body) - body["reasoning_effort"] = "high" if thinking_enabled else "none" _COHERE_EXTRA_BODY_KEYS = frozenset( @@ -72,78 +115,53 @@ def _merge_allowed_cohere_extra_body(body: dict[str, Any], extra_body: Any) -> N body.update({str(key): deepcopy(value) for key, value in extra_body.items()}) -def _apply_kimi_thinking_policy( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - if thinking_enabled: - return - extra_body = body.setdefault("extra_body", {}) - if isinstance(extra_body, dict): - extra_body["thinking"] = {"type": "disabled"} - - -def _apply_minimax_thinking_policy( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - extra_body = body.setdefault("extra_body", {}) - if not isinstance(extra_body, dict): - return - extra_body["reasoning_split"] = True - extra_body["thinking"] = ( - {"type": "adaptive"} if thinking_enabled else {"type": "disabled"} - ) - - -def _apply_ollama_thinking_policy( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - body["reasoning_effort"] = "high" if thinking_enabled else "none" - - -def _apply_wafer_thinking_policy( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - extra_body = body.setdefault("extra_body", {}) - if isinstance(extra_body, dict): - extra_body["thinking"] = ( - {"type": "enabled"} if thinking_enabled else {"type": "disabled"} - ) - - -def _apply_zai_thinking_policy( - body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool -) -> None: - extra_body = body.setdefault("extra_body", {}) - if not isinstance(extra_body, dict): - return - extra_body["thinking"] = ( - {"type": "enabled", "clear_thinking": False} - if thinking_enabled - else {"type": "disabled"} +def _policy( + provider_name: str, + replay: ReasoningReplayMode, + **kwargs: Any, +) -> OpenAIChatRequestPolicy: + return OpenAIChatRequestPolicy( + provider_name=provider_name, + reasoning_replay=replay, + **kwargs, ) OPENAI_CHAT_PROFILES: dict[str, OpenAIChatProfile] = { "mistral_codestral": OpenAIChatProfile( - OpenAIChatRequestPolicy(provider_name="CODESTRAL") + _policy("CODESTRAL", ReasoningReplayMode.THINK_TAGS), + NO_REASONING, + ), + "opencode": OpenAIChatProfile( + _policy("OPENCODE", ReasoningReplayMode.THINK_TAGS), + NO_REASONING, ), - "opencode": OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="OPENCODE")), "opencode_go": OpenAIChatProfile( - OpenAIChatRequestPolicy(provider_name="OPENCODE_GO") + _policy("OPENCODE_GO", ReasoningReplayMode.THINK_TAGS), + NO_REASONING, ), "vercel": OpenAIChatProfile( - OpenAIChatRequestPolicy(provider_name="VERCEL", include_extra_body=True) + _policy( + "VERCEL", + ReasoningReplayMode.THINK_TAGS, + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_reasoning_fields, + ), + ReasoningObject(_ALL_EFFORTS), ), "huggingface": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="HUGGINGFACE", + _policy( + "HUGGINGFACE", + ReasoningReplayMode.DISABLED, include_extra_body=True, - reasoning_replay=ReasoningReplayMode.DISABLED, - ) + extra_body_validator=validate_extra_body_does_not_override_reasoning_fields, + ), + NO_REASONING, ), "cohere": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="COHERE", + _policy( + "COHERE", + ReasoningReplayMode.REASONING_CONTENT, strip_message_names=True, unsupported_body_keys=frozenset( { @@ -160,94 +178,162 @@ OPENAI_CHAT_PROFILES: dict[str, OpenAIChatProfile] = { } ), ), + NamedEffortReasoning( + tuple((effort, "high") for effort in ReasoningEffort), + disabled_value="none", + enabled_value="high", + ), postprocessors=(_apply_cohere_request_quirks,), ), "wafer": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="WAFER", + _policy( + "WAFER", + ReasoningReplayMode.REASONING_CONTENT, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ), - postprocessors=(_apply_wafer_thinking_policy,), + NamedEffortReasoning( + _LOW_TO_MAX, + disabled_value="none", + enabled_value="high", + ), ), "kimi": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="KIMI", + _policy( + "KIMI", + ReasoningReplayMode.REASONING_CONTENT, reject_extra_body_message=( "Kimi Chat Completions API does not support caller extra_body on requests." ), default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ), - postprocessors=(_apply_kimi_thinking_policy,), + ThinkingObjectReasoning( + enabled={"type": "enabled"}, + disabled={"type": "disabled"}, + ), ), "minimax": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="MINIMAX", + _policy( + "MINIMAX", + ReasoningReplayMode.REASONING_CONTENT, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, max_tokens_field="max_completion_tokens", ), - postprocessors=(_apply_minimax_thinking_policy,), + SPLIT_REASONING_OUTPUT, ), "cerebras": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="CEREBRAS", + _policy( + "CEREBRAS", + ReasoningReplayMode.THINK_TAGS, include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_reasoning_fields, max_tokens_field="max_completion_tokens", - reasoning_replay=ReasoningReplayMode.THINK_TAGS, + ), + NamedEffortReasoning( + _LOW_MEDIUM_HIGH, + disabled_value="none", + enabled_value="medium", ), reasoning_delta_field="reasoning", ), "groq": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="GROQ", + _policy( + "GROQ", + ReasoningReplayMode.REASONING_CONTENT, include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_reasoning_fields, max_tokens_field="max_completion_tokens", strip_message_names=True, unsupported_body_keys=frozenset({"logprobs", "logit_bias", "top_logprobs"}), normalize_n_to_one=True, - ) + ), + NamedEffortReasoning( + _LOW_MEDIUM_HIGH, + disabled_value="none", + enabled_value="medium", + ), ), "sambanova": OpenAIChatProfile( - OpenAIChatRequestPolicy(provider_name="SAMBANOVA", include_extra_body=True) + _policy( + "SAMBANOVA", + ReasoningReplayMode.REASONING_CONTENT, + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_reasoning_fields, + ), + NamedEffortReasoning( + _LOW_MEDIUM_HIGH, + enabled_value="medium", + ), ), "fireworks": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="FIREWORKS", + _policy( + "FIREWORKS", + ReasoningReplayMode.REASONING_CONTENT, include_extra_body=True, extra_body_validator=validate_extra_body_does_not_override_canonical_fields, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - ) + ), + NamedEffortReasoning( + ( + (ReasoningEffort.MINIMAL, "low"), + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.MEDIUM, "medium"), + (ReasoningEffort.HIGH, "high"), + (ReasoningEffort.XHIGH, "xhigh"), + (ReasoningEffort.MAX, "max"), + ), + disabled_value="none", + enabled_value="high", + budget_field="reasoning_effort", + ), ), "zai": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="ZAI", + _policy( + "ZAI", + ReasoningReplayMode.REASONING_CONTENT, reject_extra_body_message=( "Z.ai Chat Completions API does not support caller extra_body on requests." ), default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ), - postprocessors=(_apply_zai_thinking_policy,), + ThinkingObjectReasoning( + enabled={"type": "enabled", "clear_thinking": False}, + disabled={"type": "disabled"}, + ), ), "ollama_cloud": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="OLLAMA_CLOUD", + _policy( + "OLLAMA_CLOUD", + ReasoningReplayMode.REASONING, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - reasoning_replay=ReasoningReplayMode.REASONING, ), - postprocessors=(_apply_ollama_thinking_policy,), + NamedEffortReasoning( + _LOW_TO_MAX, + disabled_value="none", + enabled_value="high", + ), reasoning_delta_field="reasoning", ), "llamacpp": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="LLAMACPP", + _policy( + "LLAMACPP", + ReasoningReplayMode.THINK_TAGS, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ), + LLAMACPP_REASONING, normalize_base_url=True, ), "ollama": OpenAIChatProfile( - OpenAIChatRequestPolicy( - provider_name="OLLAMA", + _policy( + "OLLAMA", + ReasoningReplayMode.REASONING, default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ), + NamedEffortReasoning( + _LOW_TO_MAX, + disabled_value="none", + enabled_value="high", + ), normalize_base_url=True, + reasoning_delta_field="reasoning", ), } diff --git a/src/free_claude_code/providers/openai_chat/provider.py b/src/free_claude_code/providers/openai_chat/provider.py index f4062cad5a26cd83815c561d79731ccc5968c076..dda5b63afa9d5a147b55f4e9d1447261c5857567 100644 --- a/src/free_claude_code/providers/openai_chat/provider.py +++ b/src/free_claude_code/providers/openai_chat/provider.py @@ -27,6 +27,7 @@ from free_claude_code.core.anthropic.streaming import ( tool_schemas_by_name, ) from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.core.trace import provider_chat_body_snapshot, trace_event from free_claude_code.providers.base import BaseProvider, ProviderConfig from free_claude_code.providers.failure_policy import classify_provider_failure @@ -120,24 +121,30 @@ class OpenAIChatProvider(BaseProvider): return extract_openai_model_ids(payload, provider_name=self._provider_name) def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict[str, Any]: """Build a provider request from the immutable profile.""" return build_openai_chat_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + reasoning=reasoning, policy=self._profile.request_policy, - postprocessors=self._profile.postprocessors, + postprocessors=self._profile.request_postprocessors, ) def preflight_stream( - self, request: MessagesRequest, *, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> None: """Validate OpenAI-chat request conversion before streaming.""" - self._build_request_body(request, thinking_enabled=thinking_enabled) + self._build_request_body(request, reasoning=reasoning) def _handle_extra_reasoning( - self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + self, delta: Any, ledger: AnthropicStreamLedger, *, output_reasoning: bool ) -> Iterator[str]: """Hook for provider-specific reasoning.""" return iter(()) @@ -254,7 +261,7 @@ class OpenAIChatProvider(BaseProvider): input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> AsyncIterator[str]: """Stream response in Anthropic SSE format.""" runner = _OpenAIChatStreamRunner( @@ -262,7 +269,7 @@ class OpenAIChatProvider(BaseProvider): request=request, input_tokens=input_tokens, request_id=request_id, - thinking_enabled=thinking_enabled, + reasoning=reasoning, ) return runner.run() @@ -277,13 +284,13 @@ class _OpenAIChatStreamRunner: request: MessagesRequest, input_tokens: int, request_id: str | None, - thinking_enabled: bool | None, + reasoning: ReasoningPolicy, ) -> None: self._provider = provider self._request = request self._input_tokens = input_tokens self._request_id = request_id - self._thinking_enabled = thinking_enabled + self._reasoning = reasoning self._message_id = f"msg_{uuid.uuid4()}" self._tool_calls = OpenAIToolCallAssembler( record_extra_content=provider._record_tool_call_extra_content @@ -307,12 +314,11 @@ class _OpenAIChatStreamRunner: yield from hold_event(event) body = self._provider._build_request_body( - self._request, thinking_enabled=self._thinking_enabled + self._request, + reasoning=self._reasoning, ) request_stream_usage(body) - thinking_enabled = self._provider._is_thinking_enabled( - self._request, self._thinking_enabled - ) + output_reasoning = self._reasoning.output_enabled trace_event( stage="provider", event="provider.request.sent", @@ -362,7 +368,7 @@ class _OpenAIChatStreamRunner: logger.debug("{} finish_reason: {}", tag, finish_reason) reasoning = self._provider._profile.reasoning_delta(delta) - if thinking_enabled and reasoning is not None: + if output_reasoning and reasoning is not None: for event in hold_events(ledger.ensure_thinking_block()): yield event if reasoning: @@ -374,7 +380,7 @@ class _OpenAIChatStreamRunner: for event in self._provider._handle_extra_reasoning( delta, ledger, - thinking_enabled=thinking_enabled, + output_reasoning=output_reasoning, ): for out_event in hold_event(event): yield out_event @@ -382,7 +388,7 @@ class _OpenAIChatStreamRunner: if delta.content: for part in think_parser.feed(delta.content): if part.type == ContentType.THINKING: - if not thinking_enabled: + if not output_reasoning: continue for event in hold_events( ledger.ensure_thinking_block() @@ -477,7 +483,7 @@ class _OpenAIChatStreamRunner: ledger=ledger, error=error, tool_argument_alias_buffers=tool_argument_alias_buffers, - thinking_enabled=thinking_enabled, + output_reasoning=output_reasoning, ) except Exception as recovery_error: trace_event( @@ -550,7 +556,7 @@ class _OpenAIChatStreamRunner: remaining = think_parser.flush() if remaining: if remaining.type == ContentType.THINKING: - if not thinking_enabled: + if not output_reasoning: remaining = None else: for event in hold_events(ledger.ensure_thinking_block()): @@ -701,7 +707,7 @@ class _OpenAIChatStreamRunner: ledger: AnthropicStreamLedger, error: Exception, tool_argument_alias_buffers: dict[int, str], - thinking_enabled: bool, + output_reasoning: bool, ) -> list[str] | None: """Build terminal recovery events when the interrupted stream permits it.""" if not is_retryable_stream_error(error): @@ -743,7 +749,7 @@ class _OpenAIChatStreamRunner: recovery_body = make_text_recovery_body(body, partial_text, partial_thinking) text, thinking = await self._collect_recovery_text( - recovery_body, include_reasoning=thinking_enabled + recovery_body, include_reasoning=output_reasoning ) text_suffix = continuation_suffix(partial_text, text) thinking_suffix = continuation_suffix(partial_thinking, thinking) diff --git a/src/free_claude_code/providers/openai_chat/reasoning.py b/src/free_claude_code/providers/openai_chat/reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..37412798ca4e91f2a69242558a31be94b813ee5a --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/reasoning.py @@ -0,0 +1,146 @@ +"""Provider-owned reasoning translations for OpenAI-compatible APIs.""" + +from dataclasses import dataclass +from typing import Any, Protocol + +from free_claude_code.core.reasoning import ( + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) + +EffortValues = tuple[tuple[ReasoningEffort, str], ...] + + +class ReasoningEncoder(Protocol): + """Translate provider-neutral reasoning intent into one wire shape.""" + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: ... + + +@dataclass(frozen=True, slots=True) +class NoReasoning: + """Leave reasoning computation entirely to the upstream provider.""" + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + return + + +@dataclass(frozen=True, slots=True) +class NamedEffortReasoning: + """Encode a provider's documented named-effort vocabulary.""" + + efforts: EffortValues + disabled_value: str | bool | None = None + enabled_value: str | bool | None = None + field: str = "reasoning_effort" + budget_field: str | None = None + use_extra_body: bool = False + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + target = _extra_body(body) if self.use_extra_body else body + if policy.control is ReasoningControl.OFF: + if self.disabled_value is not None: + target[self.field] = self.disabled_value + return + + if policy.budget_tokens is not None and self.budget_field is not None: + target[self.budget_field] = policy.budget_tokens + return + + effort = dict(self.efforts).get(policy.effort) + if effort is not None: + target[self.field] = effort + return + + if policy.control is ReasoningControl.ON and self.enabled_value is not None: + target[self.field] = self.enabled_value + + +@dataclass(frozen=True, slots=True) +class ReasoningObject: + """Encode gateways that accept a top-level ``reasoning`` object.""" + + efforts: EffortValues + supports_budget: bool = True + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + if policy.control is ReasoningControl.OFF: + _extra_body(body)["reasoning"] = {"enabled": False} + return + + reasoning: dict[str, Any] = {} + if policy.budget_tokens is not None and self.supports_budget: + reasoning["max_tokens"] = policy.budget_tokens + elif effort := dict(self.efforts).get(policy.effort): + reasoning["effort"] = effort + elif policy.control is ReasoningControl.ON: + reasoning["enabled"] = True + + if reasoning: + _extra_body(body)["reasoning"] = reasoning + + +@dataclass(frozen=True, slots=True) +class ThinkingObjectReasoning: + """Encode providers with an enabled/disabled ``thinking`` object.""" + + enabled: dict[str, Any] + disabled: dict[str, Any] + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + if policy.control is ReasoningControl.OFF: + _extra_body(body)["thinking"] = dict(self.disabled) + elif policy.requests_reasoning: + _extra_body(body)["thinking"] = dict(self.enabled) + + +@dataclass(frozen=True, slots=True) +class ChatTemplateReasoning: + """Encode a provider-wide chat-template boolean without model guessing.""" + + field: str = "thinking" + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + if not policy.requests_reasoning and policy.control is not ReasoningControl.OFF: + return + kwargs = _nested_dict(_extra_body(body), "chat_template_kwargs") + kwargs[self.field] = policy.control is not ReasoningControl.OFF + + +@dataclass(frozen=True, slots=True) +class LlamaCppReasoning: + """Encode llama.cpp's exact per-request numeric thinking budget.""" + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + if policy.control is ReasoningControl.OFF: + _extra_body(body)["thinking_budget_tokens"] = 0 + elif policy.budget_tokens is not None: + _extra_body(body)["thinking_budget_tokens"] = policy.budget_tokens + + +@dataclass(frozen=True, slots=True) +class SplitReasoningOutput: + """Request separate reasoning output where compute is not controllable.""" + + def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None: + _extra_body(body)["reasoning_split"] = True + + +def _extra_body(body: dict[str, Any]) -> dict[str, Any]: + value = body.setdefault("extra_body", {}) + if not isinstance(value, dict): + raise TypeError("OpenAI extra_body must be an object.") + return value + + +def _nested_dict(container: dict[str, Any], key: str) -> dict[str, Any]: + value = container.setdefault(key, {}) + if not isinstance(value, dict): + raise TypeError(f"{key} must be an object.") + return value + + +NO_REASONING = NoReasoning() +LLAMACPP_REASONING = LlamaCppReasoning() +SPLIT_REASONING_OUTPUT = SplitReasoningOutput() diff --git a/src/free_claude_code/providers/openai_chat/request_policy.py b/src/free_claude_code/providers/openai_chat/request_policy.py index 1d67fc3d6c55370e5b4a5a6ffbb08b6e4ee59071..d81e0c58040ee60e43498f84750e468e968242eb 100644 --- a/src/free_claude_code/providers/openai_chat/request_policy.py +++ b/src/free_claude_code/providers/openai_chat/request_policy.py @@ -11,9 +11,12 @@ from free_claude_code.application.errors import InvalidRequestError from free_claude_code.core.anthropic import ReasoningReplayMode, build_base_request_body from free_claude_code.core.anthropic.conversion import OpenAIConversionError from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ReasoningPolicy MaxTokensField = Literal["max_tokens", "max_completion_tokens"] -OpenAIChatPostprocessor = Callable[[dict[str, Any], MessagesRequest, bool], None] +OpenAIChatPostprocessor = Callable[ + [dict[str, Any], MessagesRequest, ReasoningPolicy], None +] ExtraBodyValidator = Callable[[dict[str, Any]], None] @@ -22,12 +25,12 @@ class OpenAIChatRequestPolicy: """Provider policy for Anthropic-to-OpenAI chat request conversion.""" provider_name: str + reasoning_replay: ReasoningReplayMode include_extra_body: bool = False extra_body_validator: ExtraBodyValidator | None = None reject_extra_body_message: str | None = None default_max_tokens: int | None = None max_tokens_field: MaxTokensField = "max_tokens" - reasoning_replay: ReasoningReplayMode | None = None strip_message_names: bool = False unsupported_body_keys: frozenset[str] = field(default_factory=frozenset) normalize_n_to_one: bool = False @@ -36,8 +39,7 @@ class OpenAIChatRequestPolicy: def build_openai_chat_request_body( request_data: MessagesRequest, *, - thinking_enabled: bool, - reasoning_history_enabled: bool | None = None, + reasoning: ReasoningPolicy, policy: OpenAIChatRequestPolicy, postprocessors: Iterable[OpenAIChatPostprocessor] = (), ) -> dict[str, Any]: @@ -49,18 +51,10 @@ def build_openai_chat_request_body( len(request_data.messages), ) try: - if reasoning_history_enabled is None: - reasoning_history_enabled = thinking_enabled - if not reasoning_history_enabled: - reasoning_replay = ReasoningReplayMode.DISABLED - else: - reasoning_replay = ( - policy.reasoning_replay or ReasoningReplayMode.REASONING_CONTENT - ) body = build_base_request_body( request_data, default_max_tokens=policy.default_max_tokens, - reasoning_replay=reasoning_replay, + reasoning_replay=policy.reasoning_replay, ) except OpenAIConversionError as exc: raise InvalidRequestError(str(exc)) from exc @@ -81,7 +75,7 @@ def build_openai_chat_request_body( _apply_common_openai_chat_policy(body, policy) for postprocess in postprocessors: - postprocess(body, request_data, thinking_enabled) + postprocess(body, request_data, reasoning) logger.debug( "{}_REQUEST: conversion done model={} msgs={} tools={}", diff --git a/src/free_claude_code/providers/runtime/config.py b/src/free_claude_code/providers/runtime/config.py index c0c28b2cc731160d4968bd9598c8e4e2144fb3e7..ead3f3aa0c49b76583a503a7da85446485fa8f82 100644 --- a/src/free_claude_code/providers/runtime/config.py +++ b/src/free_claude_code/providers/runtime/config.py @@ -61,7 +61,6 @@ def build_provider_config( http_read_timeout=settings.http_read_timeout, http_write_timeout=settings.http_write_timeout, http_connect_timeout=settings.http_connect_timeout, - enable_thinking=settings.enable_model_thinking, proxy=proxy, log_raw_sse_events=settings.log_raw_sse_events, log_api_error_tracebacks=settings.log_api_error_tracebacks, diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index 67f0271a38cd5fe5be961e27f29644e859e038a0..250da0894261d183fbac120a408d2fa6406e2b54 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -107,6 +107,15 @@ def test_admin_static_hides_managed_source_label(): assert "sourceEl.textContent = source" in script +def test_admin_static_places_reasoning_fields_in_model_config(): + script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( + encoding="utf-8" + ) + + assert 'sections: ["models", "reasoning", "web_tools"]' in script + assert 'sections: ["models", "thinking", "web_tools"]' not in script + + def test_admin_static_model_combobox_owns_dropdown_and_search_behavior(): script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( encoding="utf-8" @@ -159,7 +168,7 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): body = response.json() keys = {field["key"] for field in body["fields"]} assert "MODEL_FABLE" in keys - assert "ENABLE_FABLE_THINKING" in keys + assert "REASONING_FABLE" in keys assert "ANTHROPIC_AUTH_TOKEN" in keys assert "OPENROUTER_API_KEY" in keys assert "FIREWORKS_API_KEY" in keys @@ -206,6 +215,28 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): "MODEL_SONNET": "optional_model", "MODEL_HAIKU": "optional_model", } + reasoning_policy = next( + field for field in body["fields"] if field["key"] == "REASONING_POLICY" + ) + assert reasoning_policy["section"] == "reasoning" + assert reasoning_policy["type"] == "select" + assert reasoning_policy["value"] == "client" + assert reasoning_policy["options"] == [ + {"value": "off", "label": "Off"}, + {"value": "client", "label": "From client"}, + {"value": "low", "label": "Low"}, + {"value": "medium", "label": "Medium"}, + {"value": "high", "label": "High"}, + {"value": "xhigh", "label": "X-High"}, + {"value": "max", "label": "Max"}, + ] + route_reasoning = next( + field for field in body["fields"] if field["key"] == "REASONING_FABLE" + ) + assert route_reasoning["options"] == [ + {"value": "inherit", "label": "Inherit"}, + *reasoning_policy["options"], + ] restart_required = { field["key"] for field in body["fields"] if field["restart_required"] is True } diff --git a/tests/api/test_api.py b/tests/api/test_api.py index e8d51680e671aa3d0f3fcdb255c3ddac06087b33..7bbb8f8e8845c1921e17a3be4113d6bb174fdbc2 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -4,6 +4,7 @@ import pytest from fastapi.testclient import TestClient from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.reasoning import ReasoningPolicy from free_claude_code.providers.nvidia_nim import NvidiaNimProvider from tests.api.support import create_test_app @@ -155,7 +156,7 @@ def test_auto_mode_classifier_without_stream_returns_json(client: TestClient): assert body["usage"] == {"input_tokens": 0, "output_tokens": 0} routed_request = _stream_response_calls[0][0][0] assert routed_request.stream is False - assert _stream_response_calls[0][1]["thinking_enabled"] is False + assert _stream_response_calls[0][1]["reasoning"] == ReasoningPolicy.off() def test_create_message_ingress_error_has_request_id_without_terminal_header( @@ -277,7 +278,7 @@ def test_model_mapping(client: TestClient): args = _stream_response_calls[0][0] kwargs = _stream_response_calls[0][1] assert args[0].model != "claude-3-haiku-20240307" - assert kwargs["thinking_enabled"] is True + assert kwargs["reasoning"] == ReasoningPolicy.provider_default() @pytest.mark.parametrize( diff --git a/tests/api/test_api_handlers.py b/tests/api/test_api_handlers.py index 8db1336901ba29f48abea99f496cd59b01241716..7322006226897edf12a4e4ecf0addcfa3091b84d 100644 --- a/tests/api/test_api_handlers.py +++ b/tests/api/test_api_handlers.py @@ -21,6 +21,7 @@ from free_claude_code.core.anthropic.models import ( from free_claude_code.core.anthropic.streaming import format_sse_event from free_claude_code.core.failures import ExecutionFailure, FailureKind from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.reasoning import ReasoningPolicy _CLASSIFIER_SYSTEM = ( "You are a security monitor. Respond with yes or no." @@ -33,7 +34,7 @@ _CLASSIFIER_USER = ( class FakeProvider: def __init__(self, events: list[str] | None = None) -> None: - self.preflight_calls: list[tuple[MessagesRequest, bool | None]] = [] + self.preflight_calls: list[tuple[MessagesRequest, ReasoningPolicy]] = [] self.requests: list[MessagesRequest] = [] self.stream_kwargs: list[dict[str, Any]] = [] self.events = events or [ @@ -42,9 +43,9 @@ class FakeProvider: ] def preflight_stream( - self, request: MessagesRequest, *, thinking_enabled: bool | None = None + self, request: MessagesRequest, *, reasoning: ReasoningPolicy ) -> None: - self.preflight_calls.append((request, thinking_enabled)) + self.preflight_calls.append((request, reasoning)) async def cleanup(self) -> None: return None @@ -58,14 +59,14 @@ class FakeProvider: input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy, ) -> AsyncIterator[str]: self.requests.append(request) self.stream_kwargs.append( { "input_tokens": input_tokens, "request_id": request_id, - "thinking_enabled": thinking_enabled, + "reasoning": reasoning, } ) for event in self.events: @@ -115,7 +116,7 @@ async def test_messages_handler_passes_routed_request_and_stream_metadata() -> N assert provider.requests[0].model == "test-model" assert provider.stream_kwargs[0]["input_tokens"] > 0 assert provider.stream_kwargs[0]["request_id"].startswith("req_") - assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert provider.stream_kwargs[0]["reasoning"] == ReasoningPolicy.provider_default() assert len(provider.preflight_calls) == 1 @@ -129,7 +130,7 @@ async def test_messages_handler_preflight_invalid_request_stays_http_error( self, request: MessagesRequest, *, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy, ) -> None: raise InvalidRequestError("bad tool shape") @@ -328,14 +329,14 @@ async def test_messages_handler_stream_false_provider_exception_keeps_status() - input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy, ) -> AsyncIterator[str]: self.requests.append(request) self.stream_kwargs.append( { "input_tokens": input_tokens, "request_id": request_id, - "thinking_enabled": thinking_enabled, + "reasoning": reasoning, } ) raise ExecutionFailure( @@ -384,8 +385,8 @@ async def test_messages_handler_forces_no_thinking_for_safety_classifier() -> No assert isinstance(response, StreamingResponse) await _streaming_body_text(response) - assert provider.preflight_calls[0][1] is False - assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert provider.preflight_calls[0][1] == ReasoningPolicy.off() + assert provider.stream_kwargs[0]["reasoning"] == ReasoningPolicy.off() assert provider.requests[0].model == "test-model" assert provider.requests[0].system == _CLASSIFIER_SYSTEM assert _trace_events( @@ -426,8 +427,8 @@ async def test_messages_handler_preserves_thinking_for_non_classifier() -> None: assert isinstance(response, StreamingResponse) await _streaming_body_text(response) - assert provider.preflight_calls[0][1] is True - assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert provider.preflight_calls[0][1] == ReasoningPolicy.provider_default() + assert provider.stream_kwargs[0]["reasoning"] == ReasoningPolicy.provider_default() assert ( _trace_events( trace_mock, @@ -454,8 +455,8 @@ async def test_messages_handler_keeps_existing_no_thinking_for_classifier() -> N assert isinstance(response, StreamingResponse) await _streaming_body_text(response) - assert provider.preflight_calls[0][1] is False - assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert provider.preflight_calls[0][1] == ReasoningPolicy.off() + assert provider.stream_kwargs[0]["reasoning"] == ReasoningPolicy.off() assert _trace_events( trace_mock, "free_claude_code.api.optimization.safety_classifier_no_thinking" ) == [ @@ -530,8 +531,8 @@ async def test_responses_handler_does_not_apply_safety_classifier_policy() -> No assert isinstance(response, StreamingResponse) await _streaming_body_text(response) - assert provider.preflight_calls[0][1] is True - assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert provider.preflight_calls[0][1] == ReasoningPolicy.provider_default() + assert provider.stream_kwargs[0]["reasoning"] == ReasoningPolicy.provider_default() assert ( _trace_events( trace_mock, diff --git a/tests/api/test_openai_responses.py b/tests/api/test_openai_responses.py index 7dd7a20e17044289fa000cf30f3da3a289351598..08e448300c7ef281e97348d09edc052ef9878a9b 100644 --- a/tests/api/test_openai_responses.py +++ b/tests/api/test_openai_responses.py @@ -8,6 +8,11 @@ from free_claude_code.application.errors import InvalidRequestError from free_claude_code.core.anthropic.stream_contracts import parse_sse_text from free_claude_code.core.anthropic.streaming import format_sse_event from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.reasoning import ( + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) from tests.api.support import create_test_app @@ -590,16 +595,21 @@ def test_create_response_quarantines_malformed_prior_function_call() -> None: @pytest.mark.parametrize( - ("reasoning", "expected_type", "expected_enabled"), + ("reasoning", "expected_policy"), [ - ({"effort": "none"}, "disabled", False), - ({"effort": "low"}, "enabled", True), + ({"effort": "none"}, ReasoningPolicy.off()), + ( + {"effort": "low"}, + ReasoningPolicy( + control=ReasoningControl.DEFAULT, + effort=ReasoningEffort.LOW, + ), + ), ], ) -def test_create_response_maps_reasoning_effort_to_thinking_request( +def test_create_response_preserves_and_resolves_reasoning_effort( reasoning: dict[str, str], - expected_type: str, - expected_enabled: bool, + expected_policy: ReasoningPolicy, ) -> None: provider = FakeProvider(_anthropic_text_stream("done")) app = create_test_app() @@ -618,9 +628,11 @@ def test_create_response_maps_reasoning_effort_to_thinking_request( ) assert response.status_code == 200 - thinking = provider.requests[0].thinking - assert thinking.type == expected_type - assert thinking.enabled is expected_enabled + routed = provider.requests[0] + assert routed.thinking is None + assert routed.output_config == reasoning + assert provider.stream_kwargs[0]["reasoning"] == expected_policy + assert provider.preflight_stream.call_args.kwargs["reasoning"] == expected_policy def test_create_response_maps_redacted_thinking_to_encrypted_reasoning() -> None: diff --git a/tests/api/test_web_server_tools.py b/tests/api/test_web_server_tools.py index 8e93f84c0b25e7ba2eaa01029bf9db212c42037b..9a5613416ab6817a13b57163c1919397ec5bb04a 100644 --- a/tests/api/test_web_server_tools.py +++ b/tests/api/test_web_server_tools.py @@ -28,6 +28,7 @@ from free_claude_code.application.routing import ( RoutedMessagesRequest, ) 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, Tool from free_claude_code.core.anthropic.stream_contracts import ( @@ -35,6 +36,7 @@ from free_claude_code.core.anthropic.stream_contracts import ( parse_sse_text, text_content, ) +from free_claude_code.core.reasoning import ReasoningPolicy from free_claude_code.core.version import package_version from free_claude_code.messaging.event_parser import parse_cli_event @@ -66,11 +68,15 @@ class FixedProviderModelRouter(ModelRouter): provider_id=self._fixed_provider_id, provider_model=request.model, provider_model_ref=f"{self._fixed_provider_id}/{request.model}", - thinking_enabled=False, + reasoning_preference=ReasoningPreference.OFF, ) routed = request.model_copy(deep=True) routed.model = resolved.provider_model - return RoutedMessagesRequest(request=routed, resolved=resolved) + return RoutedMessagesRequest( + request=routed, + resolved=resolved, + reasoning=ReasoningPolicy.off(), + ) def test_web_server_tool_not_detected_when_tool_only_listed(): diff --git a/tests/application/test_execution.py b/tests/application/test_execution.py index 7e879277ab84ef590b27653196f4e553d7132141..1ada6e1243cf72691b8d35c93402c436d0f65c07 100644 --- a/tests/application/test_execution.py +++ b/tests/application/test_execution.py @@ -7,13 +7,15 @@ import pytest from free_claude_code.application.execution import ProviderExecutor from free_claude_code.application.routing import ResolvedModel, RoutedMessagesRequest +from free_claude_code.config.reasoning import ReasoningPreference from free_claude_code.core.anthropic.models import Message, MessagesRequest from free_claude_code.core.async_iterators import AsyncCloseable +from free_claude_code.core.reasoning import ReasoningPolicy class FakeProvider: def __init__(self) -> None: - self.preflight_calls: list[tuple[MessagesRequest, bool]] = [] + self.preflight_calls: list[tuple[MessagesRequest, ReasoningPolicy]] = [] self.stream_calls: list[dict[str, object]] = [] self.stream_close_calls = 0 @@ -21,9 +23,9 @@ class FakeProvider: self, request: MessagesRequest, *, - thinking_enabled: bool, + reasoning: ReasoningPolicy, ) -> None: - self.preflight_calls.append((request, thinking_enabled)) + self.preflight_calls.append((request, reasoning)) async def stream_response( self, @@ -31,14 +33,14 @@ class FakeProvider: input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy, ) -> AsyncIterator[str]: self.stream_calls.append( { "request": request, "input_tokens": input_tokens, "request_id": request_id, - "thinking_enabled": thinking_enabled, + "reasoning": reasoning, } ) try: @@ -52,7 +54,7 @@ class FailingPreflightProvider(FakeProvider): self, request: MessagesRequest, *, - thinking_enabled: bool, + reasoning: ReasoningPolicy, ) -> None: raise ValueError("invalid provider request") @@ -64,7 +66,7 @@ class FailingStreamConstructionProvider(FakeProvider): input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy, ) -> AsyncIterator[str]: raise RuntimeError("stream construction failed") @@ -81,8 +83,9 @@ def _routed_request() -> RoutedMessagesRequest: provider_id="provider", provider_model="provider-model", provider_model_ref="provider/provider-model", - thinking_enabled=True, + reasoning_preference=ReasoningPreference.CLIENT, ), + reasoning=ReasoningPolicy.on(), ) @@ -104,14 +107,14 @@ async def test_executor_uses_structural_provider_port_and_preflights_eagerly() - request_id="req_application", ) - assert provider.preflight_calls == [(request, True)] + assert provider.preflight_calls == [(request, ReasoningPolicy.on())] assert [chunk async for chunk in stream] == ["event: message_stop\ndata: {}\n\n"] assert provider.stream_calls == [ { "request": request, "input_tokens": 17, "request_id": "req_application", - "thinking_enabled": True, + "reasoning": ReasoningPolicy.on(), } ] assert provider.stream_close_calls == 1 diff --git a/tests/application/test_reasoning.py b/tests/application/test_reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..f3572ce637b490023c8fb3804f1265cfc99ebf40 --- /dev/null +++ b/tests/application/test_reasoning.py @@ -0,0 +1,129 @@ +import pytest + +from free_claude_code.application.reasoning import ( + client_reasoning_policy, + resolve_reasoning_policy, +) +from free_claude_code.config.reasoning import ReasoningPreference +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ( + ReasoningControl, + ReasoningEffort, + ReasoningPolicy, +) + + +def _request(**overrides) -> MessagesRequest: + payload = { + "model": "provider/model", + "messages": [{"role": "user", "content": "hello"}], + } + payload.update(overrides) + return MessagesRequest.model_validate(payload) + + +def test_client_without_reasoning_control_uses_provider_default() -> None: + assert client_reasoning_policy(_request()) == ReasoningPolicy.provider_default() + + +def test_client_reasoning_preserves_effort_and_exact_budget() -> None: + policy = client_reasoning_policy( + _request( + thinking={"type": "enabled", "budget_tokens": 4096}, + output_config={"effort": "xhigh"}, + ) + ) + + assert policy == ReasoningPolicy.on( + effort=ReasoningEffort.XHIGH, + budget_tokens=4096, + ) + + +def test_named_effort_does_not_invent_a_token_budget() -> None: + policy = client_reasoning_policy(_request(output_config={"effort": "high"})) + + assert policy == ReasoningPolicy( + control=ReasoningControl.DEFAULT, + effort=ReasoningEffort.HIGH, + ) + assert policy.budget_tokens is None + assert policy.requests_reasoning is True + + +def test_invalid_budget_does_not_implicitly_enable_reasoning() -> None: + policy = client_reasoning_policy(_request(thinking={"budget_tokens": 0})) + + assert policy == ReasoningPolicy.provider_default() + + +@pytest.mark.parametrize( + "messages_request", + [ + _request(thinking={"type": "disabled"}), + _request(output_config={"effort": "none"}), + ], +) +def test_client_disable_is_explicit(messages_request: MessagesRequest) -> None: + policy = client_reasoning_policy(messages_request) + + assert policy.control is ReasoningControl.OFF + assert policy.output_enabled is False + assert policy.requests_reasoning is False + + +def test_disabled_thinking_preserves_independent_effort_intent() -> None: + policy = client_reasoning_policy( + _request( + thinking={"type": "disabled"}, + output_config={"effort": "medium"}, + ) + ) + + assert policy == ReasoningPolicy( + control=ReasoningControl.OFF, + effort=ReasoningEffort.MEDIUM, + ) + assert policy.requests_reasoning is False + + +def test_fixed_route_effort_overrides_client_disable() -> None: + policy = resolve_reasoning_policy( + _request(thinking={"type": "disabled"}), + ReasoningPreference.MAX, + ) + + assert policy == ReasoningPolicy.on(effort=ReasoningEffort.MAX) + + +def test_fixed_off_overrides_client_enable() -> None: + policy = resolve_reasoning_policy( + _request(thinking={"type": "enabled", "budget_tokens": 1024}), + ReasoningPreference.OFF, + ) + + assert policy == ReasoningPolicy.off() + + +def test_client_preference_preserves_client_policy() -> None: + request = _request(output_config={"effort": "low"}) + + assert resolve_reasoning_policy( + request, ReasoningPreference.CLIENT + ) == client_reasoning_policy(request) + + +def test_unresolved_inherit_is_rejected() -> None: + with pytest.raises(ValueError, match="must be resolved"): + resolve_reasoning_policy(_request(), ReasoningPreference.INHERIT) + + +@pytest.mark.parametrize("budget", [0, -1, True]) +def test_reasoning_budget_requires_a_positive_integer(budget: int) -> None: + with pytest.raises(ValueError, match="positive integer"): + ReasoningPolicy.on(budget_tokens=budget) + + +def test_reasoning_budget_requires_explicit_on_control() -> None: + with pytest.raises(ValueError, match="control to be on"): + ReasoningPolicy(budget_tokens=100) diff --git a/tests/application/test_routing.py b/tests/application/test_routing.py index 23f363c0b4f6d55555e4f86de00aae8ff97532da..5ee6fafb35d3e5615f990d96b16abcff22f553ac 100644 --- a/tests/application/test_routing.py +++ b/tests/application/test_routing.py @@ -5,12 +5,14 @@ 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 @@ -21,11 +23,11 @@ def settings(): settings.model_opus = None settings.model_sonnet = None settings.model_haiku = None - settings.enable_model_thinking = True - settings.enable_fable_thinking = None - settings.enable_opus_thinking = None - settings.enable_sonnet_thinking = None - settings.enable_haiku_thinking = 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 @@ -36,7 +38,7 @@ def test_model_router_resolves_default_model(settings): assert resolved.provider_id == "nvidia_nim" assert resolved.provider_model == "fallback-model" assert resolved.provider_model_ref == "nvidia_nim/fallback-model" - assert resolved.thinking_enabled is True + assert resolved.reasoning_preference is ReasoningPreference.CLIENT def test_model_router_applies_opus_override(settings): @@ -52,7 +54,7 @@ def test_model_router_applies_opus_override(settings): 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.resolved.thinking_enabled is True + assert routed.reasoning.control is ReasoningControl.DEFAULT assert request.model == "claude-opus-4-20250514" @@ -72,19 +74,31 @@ def test_model_router_applies_fable_override(settings): assert routed.resolved.original_model == "claude-fable-5" -def test_model_router_resolves_per_model_thinking(settings): - settings.enable_model_thinking = False - settings.enable_fable_thinking = True - settings.enable_opus_thinking = True - settings.enable_haiku_thinking = False +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").thinking_enabled is True - assert router.resolve("claude-opus-4-20250514").thinking_enabled is True - assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False - assert router.resolve("claude-3-haiku-20240307").thinking_enabled is False - assert router.resolve("claude-2.1").thinking_enabled is False + 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): @@ -188,8 +202,6 @@ def test_model_router_routes_gateway_encoded_provider_model_directly(settings): def test_model_router_routes_no_thinking_gateway_model_directly(settings): - settings.enable_model_thinking = True - routed = ModelRouter(settings).resolve_messages_request( MessagesRequest( model="claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro", @@ -205,18 +217,23 @@ def test_model_router_routes_no_thinking_gateway_model_directly(settings): ) assert routed.resolved.provider_id == "nvidia_nim" assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro" - assert routed.resolved.thinking_enabled is False + assert routed.reasoning.control is ReasoningControl.OFF -def test_model_router_direct_prefixed_model_uses_provider_model_for_thinking(settings): - settings.enable_model_thinking = False - settings.enable_opus_thinking = True +def test_direct_provider_model_uses_root_policy_without_model_name_guessing(settings): + settings.reasoning_policy = ReasoningPreference.LOW + settings.reasoning_opus = ReasoningPreference.MAX - resolved = ModelRouter(settings).resolve("open_router/anthropic/claude-opus-4") + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="open_router/anthropic/claude-opus-4", + messages=[Message(role="user", content="hello")], + ) + ) - assert resolved.provider_id == "open_router" - assert resolved.provider_model == "anthropic/claude-opus-4" - assert resolved.thinking_enabled is True + 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): diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 34a8d9ec7b5eb00ef69cc9e036cd03e49ea86930..e205ab591d346fbad318e1d5ee514da2bd0517c2 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -424,7 +424,7 @@ def test_serve_migrates_hf_token_before_loading_settings( patch.object(commands, "get_settings", get_settings), patch.object(commands, "_run_supervised_server", return_value=False), patch.object(commands, "kill_all_best_effort"), - patch.object(commands, "explicit_env_file_huggingface_warning"), + patch.object(commands, "explicit_env_file_migration_warning"), ): commands.serve() diff --git a/tests/config/test_config.py b/tests/config/test_config.py index d02f9a4c81eaa3cb4ca38e4ce23e166083589ad5..95c03b805b00801c77be48630ec9fc9c6ac3e8b6 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -20,6 +20,7 @@ from free_claude_code.config.model_refs import ( ) from free_claude_code.config.nim import NimSettings from free_claude_code.config.paths import messaging_state_dir_path +from free_claude_code.config.reasoning import ReasoningPreference class TestSettings: @@ -47,7 +48,7 @@ class TestSettings: assert isinstance(settings.provider_rate_window, int) assert isinstance(settings.nim.temperature, float) assert isinstance(settings.fast_prefix_detection, bool) - assert isinstance(settings.enable_model_thinking, bool) + assert settings.reasoning_policy is ReasoningPreference.CLIENT assert settings.http_read_timeout == 120.0 assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT assert settings.enable_web_server_tools is False @@ -287,13 +288,24 @@ class TestSettings: assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT assert HTTP_CONNECT_TIMEOUT_DEFAULT == 10.0 - def test_enable_model_thinking_from_env(self, monkeypatch): - """ENABLE_MODEL_THINKING env var is loaded into settings.""" + def test_reasoning_policy_from_env(self, monkeypatch): + """REASONING_POLICY is loaded as a typed preference.""" from free_claude_code.config.settings import Settings - monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + monkeypatch.setenv("REASONING_POLICY", "off") settings = Settings() - assert settings.enable_model_thinking is False + assert settings.reasoning_policy is ReasoningPreference.OFF + + def test_root_reasoning_policy_cannot_inherit(self, monkeypatch): + """Only route overrides may inherit.""" + from pydantic import ValidationError + + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("REASONING_POLICY", "inherit") + + with pytest.raises(ValidationError, match="cannot inherit"): + Settings() def test_wafer_api_key_from_env(self, monkeypatch): """WAFER_API_KEY env var is loaded into settings.""" @@ -386,50 +398,65 @@ class TestSettings: assert settings.huggingface_api_key == "" assert not hasattr(settings, "hf_token") - def test_per_model_thinking_from_env(self, monkeypatch): - """Per-model thinking env vars are loaded into settings.""" + def test_route_reasoning_from_env(self, monkeypatch): + """Route reasoning preferences are loaded into settings.""" from free_claude_code.config.settings import Settings - monkeypatch.setenv("ENABLE_FABLE_THINKING", "true") - monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") - monkeypatch.setenv("ENABLE_SONNET_THINKING", "false") - monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + monkeypatch.setenv("REASONING_FABLE", "high") + monkeypatch.setenv("REASONING_OPUS", "max") + monkeypatch.setenv("REASONING_SONNET", "client") + monkeypatch.setenv("REASONING_HAIKU", "off") settings = Settings() - assert settings.enable_fable_thinking is True - assert settings.enable_opus_thinking is True - assert settings.enable_sonnet_thinking is False - assert settings.enable_haiku_thinking is False + assert settings.reasoning_fable is ReasoningPreference.HIGH + assert settings.reasoning_opus is ReasoningPreference.MAX + assert settings.reasoning_sonnet is ReasoningPreference.CLIENT + assert settings.reasoning_haiku is ReasoningPreference.OFF - def test_empty_per_model_thinking_inherits_model_default(self, monkeypatch): - """Blank per-model thinking env vars are treated as unset.""" + def test_route_reasoning_inherits_root_policy(self, monkeypatch): + """Inherit defers route reasoning to the root preference.""" from free_claude_code.application.routing import ModelRouter from free_claude_code.config.settings import Settings - monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") - monkeypatch.setenv("ENABLE_OPUS_THINKING", "") + monkeypatch.setenv("REASONING_POLICY", "off") + monkeypatch.setenv("REASONING_OPUS", "inherit") settings = Settings() - assert settings.enable_opus_thinking is None + assert settings.reasoning_opus is ReasoningPreference.INHERIT assert ( - ModelRouter(settings).resolve("claude-opus-4-20250514").thinking_enabled - is False + ModelRouter(settings).resolve("claude-opus-4-20250514").reasoning_preference + is ReasoningPreference.OFF ) - def test_resolve_thinking_uses_model_tiers(self, monkeypatch): - """ModelRouter applies tier thinking override then fallback.""" + def test_resolve_reasoning_uses_routes(self, monkeypatch): + """ModelRouter applies route preference then root fallback.""" from free_claude_code.application.routing import ModelRouter from free_claude_code.config.settings import Settings - monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") - monkeypatch.setenv("ENABLE_FABLE_THINKING", "true") - monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") - monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + monkeypatch.setenv("REASONING_POLICY", "off") + monkeypatch.setenv("REASONING_FABLE", "high") + monkeypatch.setenv("REASONING_OPUS", "max") + monkeypatch.setenv("REASONING_HAIKU", "off") settings = Settings() router = ModelRouter(settings) - assert router.resolve("claude-fable-5").thinking_enabled is True - assert router.resolve("claude-opus-4-20250514").thinking_enabled is True - assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False - assert router.resolve("claude-haiku-4-20250514").thinking_enabled is False - assert router.resolve("unknown-model").thinking_enabled is False + 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-haiku-4-20250514").reasoning_preference + is ReasoningPreference.OFF + ) + assert ( + router.resolve("unknown-model").reasoning_preference + is ReasoningPreference.OFF + ) def test_anthropic_auth_token_from_env_without_dotenv_key(self, monkeypatch): """ANTHROPIC_AUTH_TOKEN env var is loaded when dotenv does not define it.""" @@ -499,7 +526,7 @@ class TestSettings: settings = Settings() - assert settings.enable_model_thinking is True + assert settings.reasoning_policy is ReasoningPreference.CLIENT @pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"]) @pytest.mark.parametrize("value", ["false", ""]) @@ -516,7 +543,7 @@ class TestSettings: settings = Settings() - assert settings.enable_model_thinking is True + assert settings.reasoning_policy is ReasoningPreference.CLIENT # --- NimSettings Validation Tests --- diff --git a/tests/config/test_env_migrations.py b/tests/config/test_env_migrations.py index 5e23a4d3b977a96df1777807f04550eb805624be..c241e15bc8f9fbbd26e115ae27f252e7ed0570d3 100644 --- a/tests/config/test_env_migrations.py +++ b/tests/config/test_env_migrations.py @@ -1,11 +1,14 @@ from pathlib import Path +import pytest + from free_claude_code.config.env_migrations import ( HUGGINGFACE_API_KEY_ENV, HUGGINGFACE_TOKEN_MIGRATION, LEGACY_HUGGINGFACE_TOKEN_ENV, + REASONING_MIGRATIONS, env_text_needs_migration, - explicit_env_file_huggingface_warning, + explicit_env_file_migration_warning, migrate_env_key_in_file, migrate_env_key_in_text, migrate_owned_env_files, @@ -77,16 +80,60 @@ def test_migrate_owned_env_files_rewrites_repo_and_managed_env( ) -def test_explicit_env_file_huggingface_warning_does_not_rewrite( +def test_explicit_env_file_migration_warning_does_not_rewrite( tmp_path: Path, ) -> None: explicit = tmp_path / "custom.env" explicit.write_text("HF_TOKEN=explicit-token\n", encoding="utf-8") - warning = explicit_env_file_huggingface_warning({"FCC_ENV_FILE": str(explicit)}) + warning = explicit_env_file_migration_warning({"FCC_ENV_FILE": str(explicit)}) assert warning is not None assert str(explicit) in warning assert LEGACY_HUGGINGFACE_TOKEN_ENV in warning assert HUGGINGFACE_API_KEY_ENV in warning assert explicit.read_text(encoding="utf-8") == "HF_TOKEN=explicit-token\n" + + +def test_reasoning_migrations_rename_and_map_boolean_values() -> None: + text = ( + "ENABLE_MODEL_THINKING=false\n" + "ENABLE_FABLE_THINKING=true\n" + "ENABLE_OPUS_THINKING=\n" + ) + + for migration in REASONING_MIGRATIONS: + text, _ = migrate_env_key_in_text(text, migration) + + assert text == ( + "REASONING_POLICY=off\nREASONING_FABLE=client\nREASONING_OPUS=inherit\n" + ) + + +@pytest.mark.parametrize( + ("legacy_value", "expected"), + [ + ("1", "client"), + ("TRUE", "client"), + ("t", "client"), + ("on", "client"), + ("yes", "client"), + ("y", "client"), + ("0", "off"), + ("FALSE", "off"), + ("f", "off"), + ("off", "off"), + ("no", "off"), + ("n", "off"), + ], +) +def test_reasoning_migration_accepts_every_legacy_boolean_spelling( + legacy_value: str, + expected: str, +) -> None: + text = f"ENABLE_MODEL_THINKING={legacy_value}\n" + + migrated, changed = migrate_env_key_in_text(text, REASONING_MIGRATIONS[0]) + + assert changed is True + assert migrated == f"REASONING_POLICY={expected}\n" diff --git a/tests/core/openai_responses/test_conversion.py b/tests/core/openai_responses/test_conversion.py index 60619e1e7418376dc9cd9f5fb1d39eaf8449b0c1..176c04f0bf40f5e85da0fc39afc402db0985e1da 100644 --- a/tests/core/openai_responses/test_conversion.py +++ b/tests/core/openai_responses/test_conversion.py @@ -37,6 +37,22 @@ def test_responses_string_input_converts_to_anthropic_message() -> None: assert payload["metadata"] == {"trace": "abc"} +@pytest.mark.parametrize("effort", ["none", "low", "medium", "high", "xhigh"]) +def test_responses_reasoning_effort_is_preserved_for_application_policy( + effort: str, +) -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Hello", + "reasoning": {"effort": effort}, + } + ) + + assert payload["output_config"] == {"effort": effort} + assert "thinking" not in payload + + def test_responses_messages_tools_and_tool_results_convert() -> None: payload = _to_anthropic_payload( { diff --git a/tests/providers/support.py b/tests/providers/support.py index 1f3266cc5f97efa01c693e28807fc8e72935a66e..16cfe5419a8a88610fcf387bbfd387935d41c9d7 100644 --- a/tests/providers/support.py +++ b/tests/providers/support.py @@ -3,6 +3,9 @@ from collections.abc import Callable from typing import Any +from free_claude_code.application.reasoning import client_reasoning_policy +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.reasoning import ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( OpenAIChatProvider, @@ -10,6 +13,10 @@ from free_claude_code.providers.openai_chat import ( ) from free_claude_code.providers.rate_limit import ProviderRateLimiter +REASONING_DEFAULT = ReasoningPolicy.provider_default() +REASONING_ON = ReasoningPolicy.on() +REASONING_OFF = ReasoningPolicy.off() + class PassthroughProviderRateLimiter(ProviderRateLimiter): """Skip retry timing while retaining the real concurrency context manager.""" @@ -74,3 +81,9 @@ def retrying_rate_limiter() -> ProviderRateLimiter: rate_window=1.0, max_concurrency=1_000, ) + + +def reasoning_for(request: MessagesRequest) -> ReasoningPolicy: + """Resolve provider-test input through the production client boundary.""" + + return client_reasoning_policy(request) diff --git a/tests/providers/test_cerebras.py b/tests/providers/test_cerebras.py index de7231206fa0acdf28fa83c5827930af205b67b6..395f0ce96a3597a77697daa5b5175671df0beb11 100644 --- a/tests/providers/test_cerebras.py +++ b/tests/providers/test_cerebras.py @@ -7,7 +7,12 @@ import pytest from free_claude_code.config.provider_catalog import CEREBRAS_DEFAULT_BASE from free_claude_code.providers.base import ProviderConfig from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) def make_request(model="llama3.1-8b", **overrides): @@ -53,7 +58,6 @@ def cerebras_config(): base_url=CEREBRAS_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -84,7 +88,7 @@ def test_default_base_url_constant(): def test_build_request_body_basic(cerebras_provider): """Basic request body conversion attaches system message from Claude request.""" req = make_request() - body = cerebras_provider._build_request_body(req) + body = cerebras_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["model"] == "llama3.1-8b" assert body["messages"][0]["role"] == "system" @@ -112,7 +116,7 @@ def test_build_request_body_replays_reasoning_as_tagged_content(cerebras_provide ) -def test_build_request_body_global_disable_blocks_reasoning_mapping(): +def test_replay_is_independent_of_current_turn_reasoning_control(): provider = profiled_provider( "cerebras", ProviderConfig( @@ -120,23 +124,21 @@ def test_build_request_body_global_disable_blocks_reasoning_mapping(): base_url=CEREBRAS_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) - body = provider._build_request_body(make_reasoning_tool_history_request()) + body = provider._build_request_body( + make_reasoning_tool_history_request(), reasoning=REASONING_OFF + ) assistant = next( message for message in body["messages"] if message["role"] == "assistant" ) - assert assistant["content"] == "I will inspect the file." - assert assistant["tool_calls"][0]["id"] == "toolu_1" - assert all( - "" not in str(message.get("content", "")) - and "reasoning_content" not in message - and "reasoning" not in message - for message in body["messages"] + assert assistant["content"] == ( + "\nI need to read it first.\n\n\nI will inspect the file." ) + assert assistant["tool_calls"][0]["id"] == "toolu_1" + assert body["reasoning_effort"] == "none" def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_provider): @@ -150,7 +152,7 @@ def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_pr "max_tokens": 42, } req = make_request() - body = cerebras_provider._build_request_body(req) + body = cerebras_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["messages"][0].get("name") == "alice" assert body.get("max_tokens") is None @@ -176,7 +178,7 @@ def test_build_request_body_prefers_existing_max_completion_tokens(cerebras_prov def test_build_request_body_preserves_caller_extra_body(cerebras_provider): req = make_request(extra_body={"clear_thinking": False}) - body = cerebras_provider._build_request_body(req) + body = cerebras_provider._build_request_body(req, reasoning=reasoning_for(req)) eb = body.get("extra_body") assert isinstance(eb, dict) diff --git a/tests/providers/test_cloudflare.py b/tests/providers/test_cloudflare.py index 3d6411c321c26524300071d6f744b06f889d36df..af1adfe64bd42c36dd0f79e2b569671c94b641d9 100644 --- a/tests/providers/test_cloudflare.py +++ b/tests/providers/test_cloudflare.py @@ -16,7 +16,7 @@ from free_claude_code.providers.cloudflare import ( CloudflareProvider, cloudflare_ai_base_url, ) -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import passthrough_rate_limiter, reasoning_for _ACCOUNT_ID = "account-123" _BASE_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/v1" @@ -30,7 +30,6 @@ def cloudflare_config() -> ProviderConfig: base_url=CLOUDFLARE_AI_REST_ROOT, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -122,7 +121,9 @@ def test_build_request_body_preserves_literal_cf_model_id_and_controls_thinking( } ) - body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + body = cloudflare_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["model"] == "@cf/moonshotai/kimi-k2.6" assert body["max_completion_tokens"] == 100 @@ -141,7 +142,9 @@ def test_build_request_body_disabled_thinking_sets_cloudflare_template_flag( } ) - body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + body = cloudflare_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False @@ -157,7 +160,9 @@ def test_build_request_body_preserves_user_extra_body_without_overriding_thinkin } ) - body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + body = cloudflare_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False diff --git a/tests/providers/test_codestral.py b/tests/providers/test_codestral.py index 72094e159486f8ad1b34324c03927d6bfb0ce08a..762413cc11f29128a8f074e5be8cbabe295b6564 100644 --- a/tests/providers/test_codestral.py +++ b/tests/providers/test_codestral.py @@ -21,7 +21,6 @@ def codestral_config(): base_url=CODESTRAL_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -69,7 +68,6 @@ def test_build_request_body_global_disable_blocks_reasoning_mapping(): base_url=CODESTRAL_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) diff --git a/tests/providers/test_cohere.py b/tests/providers/test_cohere.py index e151b83f00fd711477ad71531ac5f60850901a67..d46ac6a3757b8cb432db6dade762df7276332093 100644 --- a/tests/providers/test_cohere.py +++ b/tests/providers/test_cohere.py @@ -9,7 +9,11 @@ from free_claude_code.application.errors import InvalidRequestError from free_claude_code.config.provider_catalog import COHERE_DEFAULT_BASE from free_claude_code.providers.base import ProviderConfig from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) def make_request(**overrides): @@ -23,7 +27,6 @@ def cohere_config(): base_url=COHERE_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -102,8 +105,11 @@ def test_build_request_body_sanitizes_documented_unsupported_fields(cohere_provi assert key not in body -def test_build_request_body_maps_thinking_enabled_to_reasoning_high(cohere_provider): - body = cohere_provider._build_request_body(make_request()) +def test_build_request_body_maps_reasoning_on_to_high(cohere_provider): + request = make_request() + body = cohere_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["reasoning_effort"] == "high" @@ -123,7 +129,10 @@ def test_build_request_body_preserves_replayed_reasoning_content(cohere_provider ], } - body = cohere_provider._build_request_body(make_request()) + request = make_request() + body = cohere_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"] == [ { @@ -135,7 +144,7 @@ def test_build_request_body_preserves_replayed_reasoning_content(cohere_provider assert body["reasoning_effort"] == "high" -def test_build_request_body_maps_thinking_disabled_to_reasoning_none(): +def test_build_request_body_maps_reasoning_off_to_none(): provider = profiled_provider( "cohere", ProviderConfig( @@ -143,12 +152,12 @@ def test_build_request_body_maps_thinking_disabled_to_reasoning_none(): base_url=COHERE_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) - body = provider._build_request_body(make_request()) + request = make_request(thinking={"type": "disabled"}) + body = provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["reasoning_effort"] == "none" @@ -163,7 +172,7 @@ def test_build_request_body_promotes_allowed_extra_body(cohere_provider): } ) - body = cohere_provider._build_request_body(req) + body = cohere_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["frequency_penalty"] == 0.1 assert body["presence_penalty"] == 0.2 @@ -176,7 +185,7 @@ def test_build_request_body_rejects_unsupported_extra_body(cohere_provider): req = make_request(extra_body={"documents": [{"text": "x"}]}) with pytest.raises(InvalidRequestError, match="Unsupported"): - cohere_provider._build_request_body(req) + cohere_provider._build_request_body(req, reasoning=reasoning_for(req)) @pytest.mark.asyncio diff --git a/tests/providers/test_deepseek.py b/tests/providers/test_deepseek.py index 2cfe68b7dff9182049c2b36aa2c22a30d90ae60d..e7b1d3ee121500063308177342ee77a694bd01ca 100644 --- a/tests/providers/test_deepseek.py +++ b/tests/providers/test_deepseek.py @@ -21,7 +21,12 @@ from free_claude_code.core.anthropic.models import ( from free_claude_code.core.anthropic.stream_contracts import parse_sse_text from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.deepseek import DeepSeekProvider -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import ( + REASONING_OFF, + REASONING_ON, + passthrough_rate_limiter, + reasoning_for, +) @pytest.fixture @@ -31,7 +36,6 @@ def deepseek_config(): base_url=DEEPSEEK_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -93,7 +97,9 @@ def test_build_request_body_openai_chat_shape(deepseek_provider): messages=[Message(role="user", content="Hello")], system="S", ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["model"] == "deepseek-v4-pro" assert "stream" not in body assert body["messages"][0] == {"role": "system", "content": "S"} @@ -108,7 +114,9 @@ def test_build_request_body_default_max_tokens(deepseek_provider): model="m", messages=[Message(role="user", content="x")], ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -120,7 +128,9 @@ def test_build_request_body_thinking_enabled(deepseek_provider): "thinking": {"type": "enabled", "budget_tokens": 2000}, } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} @@ -140,7 +150,9 @@ def test_build_request_body_tool_list_keeps_thinking(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["tools"][0]["function"]["name"] == "Read" @@ -156,7 +168,9 @@ def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["tool_choice"] == "auto" @@ -181,20 +195,21 @@ def test_build_request_body_forced_tool_choice_downgrades_to_auto( } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["tool_choice"] == "auto" -def test_build_request_body_respects_global_thinking_disable(): +def test_build_request_body_encodes_reasoning_off(): provider = DeepSeekProvider( ProviderConfig( api_key="k", base_url=DEEPSEEK_DEFAULT_BASE, rate_limit=1, rate_window=1, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) @@ -205,8 +220,8 @@ def test_build_request_body_respects_global_thinking_disable(): "thinking": {"type": "enabled", "budget_tokens": 1}, } ) - body = provider._build_request_body(request) - assert "extra_body" not in body + body = provider._build_request_body(request, reasoning=REASONING_OFF) + assert body["extra_body"]["thinking"] == {"type": "disabled"} assert "stream_options" not in body @@ -229,7 +244,9 @@ def test_non_tool_thinking_is_omitted_from_first_replay(deepseek_provider): ], } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0] == {"role": "assistant", "content": "out"} @@ -248,7 +265,9 @@ def test_strip_redacted_thinking_when_thinking_on(deepseek_provider): ], } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0] == {"role": "assistant", "content": "out"} @@ -293,9 +312,11 @@ def test_tool_history_with_replayable_thinking_preserves_thinking(deepseek_provi } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) - assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["reasoning_effort"] == "high" assert "context_management" not in body assert "output_config" not in body assistant = body["messages"][0] @@ -342,7 +363,9 @@ def test_tool_history_with_unsigned_thinking_preserves_thinking(deepseek_provide } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["messages"][0]["reasoning_content"] == "plain" @@ -395,9 +418,11 @@ def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_prov } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) - assert "extra_body" not in body + assert body["extra_body"]["thinking"] == {"type": "disabled"} assert "context_management" not in body assert "output_config" not in body assert body["tools"][0]["function"]["name"] == "Read" @@ -438,7 +463,9 @@ def test_tool_history_with_empty_thinking_preserves_reasoning_state(deepseek_pro } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["messages"][0]["reasoning_content"] == "" @@ -479,7 +506,9 @@ def test_tool_history_with_empty_top_level_reasoning_preserves_reasoning_state( } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["messages"][0]["reasoning_content"] == "" @@ -493,7 +522,6 @@ def test_thinking_off_strips_thinking_history(): base_url=DEEPSEEK_DEFAULT_BASE, rate_limit=1, rate_window=1, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) @@ -511,7 +539,7 @@ def test_thinking_off_strips_thinking_history(): ], } ) - body = provider._build_request_body(request) + body = provider._build_request_body(request, reasoning=REASONING_OFF) assert "reasoning_content" not in body["messages"][0] assert "sec" not in str(body["messages"]) @@ -523,7 +551,6 @@ def test_thinking_off_still_replays_required_tool_reasoning(): base_url=DEEPSEEK_DEFAULT_BASE, rate_limit=1, rate_window=1, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) @@ -557,9 +584,9 @@ def test_thinking_off_still_replays_required_tool_reasoning(): } ) - body = provider._build_request_body(request) + body = provider._build_request_body(request, reasoning=REASONING_OFF) - assert "extra_body" not in body + assert body["extra_body"]["thinking"] == {"type": "disabled"} assert body["messages"][0]["reasoning_content"] == "required" @@ -592,7 +619,9 @@ def test_passthrough_tool_use_and_result(deepseek_provider): ], } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "n" assert body["messages"][1]["role"] == "tool" @@ -627,8 +656,8 @@ def test_preflight_strips_user_image(): rate_limiter=passthrough_rate_limiter(), ) # Should not raise; image is stripped. - provider.preflight_stream(request, thinking_enabled=True) - body = provider._build_request_body(request) + provider.preflight_stream(request, reasoning=REASONING_ON) + body = provider._build_request_body(request, reasoning=reasoning_for(request)) content = body["messages"][0]["content"] assert "attachment omitted" in content.lower() assert "image or document inputs" in content.lower() @@ -720,7 +749,9 @@ def test_non_tool_top_level_reasoning_is_not_replayed(deepseek_provider): ) ], ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0] == {"role": "assistant", "content": "hi"} @@ -755,7 +786,9 @@ def test_tool_call_top_level_reasoning_is_replayed(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0]["reasoning_content"] == "required" @@ -831,7 +864,9 @@ async def test_wire_messages_keep_prefix_across_tool_thinking_fallback( "thinking": {"type": "enabled"}, } ) - return deepseek_provider._build_request_body(request) + return deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) first_wire = await _capture_openai_wire_body(build(prefix_messages)) continued_wire = await _capture_openai_wire_body(build(continued_messages)) @@ -845,7 +880,7 @@ async def test_wire_messages_keep_prefix_across_tool_thinking_fallback( assert "reasoning_content" not in assistant_messages[0] assert assistant_messages[1]["reasoning_content"] == "required tool reasoning" assert first_wire["thinking"] == {"type": "enabled"} - assert "thinking" not in continued_wire + assert continued_wire["thinking"] == {"type": "disabled"} @pytest.mark.asyncio @@ -923,8 +958,8 @@ def test_preserves_extra_body_for_openai_chat_request(deepseek_provider): "extra_body": {"note": 1}, } r = MessagesRequest.model_validate(raw) - body = deepseek_provider._build_request_body(r) - assert body["extra_body"] == {"note": 1, "thinking": {"type": "enabled"}} + body = deepseek_provider._build_request_body(r, reasoning=reasoning_for(r)) + assert body["extra_body"] == {"note": 1} def test_normalizes_tool_result_content_array_to_string(deepseek_provider): @@ -961,7 +996,9 @@ def test_normalizes_tool_result_content_array_to_string(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) tool_result = body["messages"][1] assert tool_result["role"] == "tool" @@ -995,7 +1032,9 @@ def test_strips_document_blocks_for_deepseek(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0] == { "role": "tool", @@ -1028,7 +1067,9 @@ def test_strips_image_blocks_for_deepseek(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["messages"][0] == {"role": "user", "content": "describe this"} @@ -1064,7 +1105,9 @@ def test_normalizes_tool_result_content_dict_to_string(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) tool_result = body["messages"][1] assert tool_result["role"] == "tool" @@ -1114,7 +1157,9 @@ def test_strips_image_block_inside_tool_result(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) tool_result = body["messages"][1] assert tool_result["role"] == "tool" @@ -1165,7 +1210,9 @@ def test_image_only_tool_result_replaced_with_placeholder(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) tool_result = body["messages"][1] assert tool_result["role"] == "tool" @@ -1216,7 +1263,9 @@ def test_document_only_tool_result_replaced_with_generic_placeholder( } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) tool_result = body["messages"][1] assert tool_result["role"] == "tool" @@ -1249,7 +1298,9 @@ def test_image_only_message_replaced_with_placeholder(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) content = body["messages"][0]["content"] assert "attachment omitted" in content.lower() @@ -1275,7 +1326,9 @@ def test_document_only_message_replaced_with_placeholder(deepseek_provider): } ) - body = deepseek_provider._build_request_body(request) + body = deepseek_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) content = body["messages"][0]["content"] assert "attachment omitted" in content.lower() @@ -1337,7 +1390,7 @@ def test_warns_when_stripping_attachment_blocks(deepseek_provider, caplog): ) with caplog.at_level(logging.WARNING): - deepseek_provider._build_request_body(request) + deepseek_provider._build_request_body(request, reasoning=reasoning_for(request)) warnings = [r for r in caplog.records if r.levelno == logging.WARNING] assert any("stripped unsupported attachment blocks" in r.message for r in warnings) @@ -1353,7 +1406,7 @@ def test_no_warning_when_no_attachments(deepseek_provider, caplog): ) with caplog.at_level(logging.WARNING): - deepseek_provider._build_request_body(request) + deepseek_provider._build_request_body(request, reasoning=reasoning_for(request)) assert not any( "stripped unsupported attachment blocks" in r.message diff --git a/tests/providers/test_fireworks.py b/tests/providers/test_fireworks.py index fb1deef5a4a39348b44383c9e8a662591f78ca19..f0df293bc2e010a5af2f4272a182371638b6edb7 100644 --- a/tests/providers/test_fireworks.py +++ b/tests/providers/test_fireworks.py @@ -10,7 +10,12 @@ from free_claude_code.config.provider_catalog import FIREWORKS_DEFAULT_BASE from free_claude_code.core.anthropic.models import Message, MessagesRequest from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) @pytest.fixture @@ -22,7 +27,6 @@ def fireworks_provider(): base_url=FIREWORKS_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ), rate_limiter=passthrough_rate_limiter(), ) @@ -46,7 +50,9 @@ def test_build_request_body_openai_chat_shape(fireworks_provider): system="System prompt", ) - body = fireworks_provider._build_request_body(request) + body = fireworks_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["model"] == "accounts/fireworks/models/glm-5p1" assert body["max_tokens"] == 100 @@ -62,12 +68,14 @@ def test_build_request_body_default_max_tokens(fireworks_provider): messages=[Message(role="user", content="x")], ) - body = fireworks_provider._build_request_body(request) + body = fireworks_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -def test_build_request_body_global_disable_blocks_thinking(): +def test_replay_is_independent_of_current_turn_reasoning_control(): provider = profiled_provider( "fireworks", ProviderConfig( @@ -75,7 +83,6 @@ def test_build_request_body_global_disable_blocks_thinking(): base_url=FIREWORKS_DEFAULT_BASE, rate_limit=1, rate_window=1, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) @@ -91,9 +98,10 @@ def test_build_request_body_global_disable_blocks_thinking(): } ) - body = provider._build_request_body(request) + body = provider._build_request_body(request, reasoning=REASONING_OFF) - assert "reasoning_content" not in body["messages"][0] + assert body["messages"][0]["reasoning_content"] == "hidden" + assert body["reasoning_effort"] == "none" def test_build_request_body_preserves_validated_extra_body(fireworks_provider): @@ -105,7 +113,9 @@ def test_build_request_body_preserves_validated_extra_body(fireworks_provider): } ) - body = fireworks_provider._build_request_body(request) + body = fireworks_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["extra_body"] == {"custom_param": "value"} @@ -120,7 +130,9 @@ def test_build_request_body_rejects_reserved_extra_body_keys(fireworks_provider) ) with pytest.raises(InvalidRequestError, match="extra_body must not override"): - fireworks_provider._build_request_body(request) + fireworks_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) @pytest.mark.asyncio diff --git a/tests/providers/test_gemini.py b/tests/providers/test_gemini.py index 5315f1866f0818074972f02c64927bea00091732..7193ffbc08dc4c863544f2aec60cce99e6858787 100644 --- a/tests/providers/test_gemini.py +++ b/tests/providers/test_gemini.py @@ -11,7 +11,7 @@ from free_claude_code.providers.gemini.quirks import ( GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import passthrough_rate_limiter, reasoning_for def make_request(**overrides): @@ -33,7 +33,6 @@ def gemini_config(): base_url=GEMINI_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -67,7 +66,7 @@ def test_default_base_url_constant(): def test_build_request_body_basic(gemini_provider): """Basic body conversion attaches Gemini thinking fields when thinking is on.""" req = make_request() - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["model"] == "models/gemini-3.1-flash-lite" assert body["messages"][0]["role"] == "system" @@ -88,7 +87,7 @@ def test_build_request_body_sdk_wire_json_has_literal_extra_body(gemini_provider """Regression for issue #542: SDK merge must not send top-level google.""" req = make_request() - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) wire_json = _simulate_openai_sdk_wire_json(body) assert "reasoning_effort" not in wire_json @@ -102,7 +101,7 @@ def test_build_request_body_sdk_wire_json_has_literal_extra_body(gemini_provider assert thinking_config.get("include_thoughts") is True -def test_build_request_body_global_disable_sets_reasoning_none(): +def test_build_request_body_reasoning_off_sets_reasoning_none(): """When thinking is off, Gemini uses reasoning_effort none (Gemini 2.5 convention).""" provider = GeminiProvider( ProviderConfig( @@ -110,12 +109,11 @@ def test_build_request_body_global_disable_sets_reasoning_none(): base_url=GEMINI_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) - req = make_request() - body = provider._build_request_body(req) + req = make_request(thinking={"type": "disabled"}) + body = provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["reasoning_effort"] == "none" roles = [m.get("role") for m in body.get("messages", [])] @@ -125,7 +123,7 @@ def test_build_request_body_global_disable_sets_reasoning_none(): def test_build_request_body_preserves_caller_extra_body(gemini_provider): req = make_request(extra_body={"metadata": {"user": "u1"}}) - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) assert "reasoning_effort" not in body eb = body.get("extra_body") @@ -150,7 +148,7 @@ def test_build_request_body_merges_caller_nested_google(gemini_provider): } ) - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) assert "reasoning_effort" not in body eb = body.get("extra_body") @@ -199,7 +197,7 @@ def test_build_request_body_preserves_tool_call_extra_content(gemini_provider): ], ) - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) tool_call = body["messages"][1]["tool_calls"][0] assert tool_call["extra_content"] == { @@ -239,7 +237,7 @@ def test_build_request_body_uses_cached_tool_call_signature(gemini_provider): ], ) - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) tool_call = body["messages"][1]["tool_calls"][0] assert tool_call["extra_content"] == { @@ -247,7 +245,7 @@ def test_build_request_body_uses_cached_tool_call_signature(gemini_provider): } -def test_build_request_body_adds_gemini3_current_turn_fallback_signature( +def test_build_request_body_adds_current_turn_fallback_signature( gemini_provider, ): req = make_request( @@ -289,7 +287,7 @@ def test_build_request_body_adds_gemini3_current_turn_fallback_signature( ], ) - body = gemini_provider._build_request_body(req) + body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req)) tool_calls = body["messages"][1]["tool_calls"] assert tool_calls[0]["extra_content"] == { @@ -300,7 +298,7 @@ def test_build_request_body_adds_gemini3_current_turn_fallback_signature( @pytest.mark.asyncio async def test_stream_response_text(gemini_provider): - req = make_request() + req = make_request(thinking={"type": "enabled"}) mock_chunk = MagicMock() mock_chunk.choices = [ @@ -323,7 +321,12 @@ async def test_stream_response_text(gemini_provider): ) as mock_create: mock_create.return_value = mock_stream() - events = [event async for event in gemini_provider.stream_response(req)] + events = [ + event + async for event in gemini_provider.stream_response( + req, reasoning=reasoning_for(req) + ) + ] assert any( '"text_delta"' in event and "Hello back!" in event for event in events diff --git a/tests/providers/test_github_models.py b/tests/providers/test_github_models.py index ec407517f706ec338e42554d33e7afe0f1995016..422c7e00ba04c90b8f0bf4f3871d6a145a1ae035 100644 --- a/tests/providers/test_github_models.py +++ b/tests/providers/test_github_models.py @@ -15,7 +15,7 @@ from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.github_models import GitHubModelsProvider from free_claude_code.providers.github_models.client import GITHUB_MODELS_CATALOG_URL from free_claude_code.providers.model_listing import ModelListResponseError -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import REASONING_ON, passthrough_rate_limiter @pytest.fixture @@ -25,7 +25,6 @@ def github_models_config() -> ProviderConfig: base_url=GITHUB_MODELS_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -118,7 +117,7 @@ def test_build_request_body_uses_shared_openai_chat_policy( ) -> None: request = _request() - body = github_models_provider._build_request_body(request, thinking_enabled=True) + body = github_models_provider._build_request_body(request, reasoning=REASONING_ON) assert body["model"] == "openai/gpt-4.1" assert body["max_tokens"] == 100 diff --git a/tests/providers/test_groq.py b/tests/providers/test_groq.py index 5960ae3e1cd13b689a60acade9416fde5612f310..857a03907485d514a194a03cad9d2655742dac5e 100644 --- a/tests/providers/test_groq.py +++ b/tests/providers/test_groq.py @@ -21,7 +21,6 @@ def groq_config(): base_url=GROQ_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -67,7 +66,6 @@ def test_build_request_body_global_disable_blocks_reasoning_mapping(): base_url=GROQ_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) diff --git a/tests/providers/test_huggingface.py b/tests/providers/test_huggingface.py index 60fa69a22365992e72c8d73b88d29cb66763baa5..bb13f1870fb336cdd1aa4880c7d4fba09abf144e 100644 --- a/tests/providers/test_huggingface.py +++ b/tests/providers/test_huggingface.py @@ -7,6 +7,7 @@ import pytest from free_claude_code.config.provider_catalog import HUGGINGFACE_DEFAULT_BASE from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from tests.providers.request_factory import make_messages_request from tests.providers.support import passthrough_rate_limiter, profiled_provider @@ -23,7 +24,6 @@ def huggingface_config(): base_url=HUGGINGFACE_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -95,6 +95,28 @@ def test_build_request_body_preserves_caller_extra_body(huggingface_provider): assert body["extra_body"]["routing"] is not extra_body["routing"] +@pytest.mark.parametrize( + "reasoning", + ( + ReasoningPolicy.off(), + ReasoningPolicy.on(effort=ReasoningEffort.MAX), + ReasoningPolicy.on(budget_tokens=4096), + ), +) +def test_build_request_body_leaves_reasoning_control_to_selected_upstream( + huggingface_provider, reasoning +): + body = huggingface_provider._build_request_body( + make_request(), + reasoning=reasoning, + ) + + assert "reasoning_effort" not in body + assert "reasoning" not in body + assert "thinking" not in body + assert "extra_body" not in body + + def test_build_request_body_does_not_replay_prior_thinking_blocks( huggingface_provider, ): diff --git a/tests/providers/test_kimi.py b/tests/providers/test_kimi.py index e8e6c401635074562ded8f494c1bdaba78d16cc0..618a21991eb862e8872cdb93c5501f039ef037ab 100644 --- a/tests/providers/test_kimi.py +++ b/tests/providers/test_kimi.py @@ -11,7 +11,11 @@ from free_claude_code.config.provider_catalog import KIMI_DEFAULT_BASE from free_claude_code.core.anthropic.models import Message, MessagesRequest from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) @pytest.fixture @@ -23,7 +27,6 @@ def kimi_provider(): base_url=KIMI_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ), rate_limiter=passthrough_rate_limiter(), ) @@ -42,7 +45,7 @@ def test_build_request_body_openai_chat(kimi_provider): messages=[Message(role="user", content="hi")], ) - body = kimi_provider._build_request_body(request) + body = kimi_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["model"] == "kimi-k2.5" assert body["max_tokens"] == 50 @@ -56,7 +59,7 @@ def test_build_request_body_default_max_tokens(kimi_provider): messages=[Message(role="user", content="x")], ) - body = kimi_provider._build_request_body(request) + body = kimi_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -71,7 +74,7 @@ def test_build_request_body_rejects_caller_extra_body(kimi_provider): ) with pytest.raises(InvalidRequestError, match="Kimi Chat Completions"): - kimi_provider._build_request_body(request) + kimi_provider._build_request_body(request, reasoning=reasoning_for(request)) def test_build_request_body_disables_kimi_thinking(kimi_provider): @@ -83,7 +86,7 @@ def test_build_request_body_disables_kimi_thinking(kimi_provider): } ) - body = kimi_provider._build_request_body(request) + body = kimi_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["extra_body"]["thinking"] == {"type": "disabled"} diff --git a/tests/providers/test_llamacpp.py b/tests/providers/test_llamacpp.py index 462a01de4c5d299863bf023c2caa9de308801f0a..412512d41d5dfddbd2e2508112dca27220a6993a 100644 --- a/tests/providers/test_llamacpp.py +++ b/tests/providers/test_llamacpp.py @@ -9,7 +9,12 @@ from free_claude_code.core.anthropic.stream_contracts import parse_sse_text from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) LLAMACPP_MODEL = "llamacpp-community/qwen2.5-7b-instruct" @@ -73,7 +78,7 @@ def test_build_request_body_uses_openai_chat_shape( ) -> None: request = make_messages_request(LLAMACPP_MODEL, max_tokens=None) - body = provider._build_request_body(request) + body = provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["model"] == LLAMACPP_MODEL assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -81,7 +86,7 @@ def test_build_request_body_uses_openai_chat_shape( assert "thinking" not in body -def test_disabled_thinking_does_not_replay_assistant_reasoning( +def test_replay_is_independent_of_current_turn_reasoning_control( provider: OpenAIChatProvider, ) -> None: request = make_messages_request( @@ -99,10 +104,10 @@ def test_disabled_thinking_does_not_replay_assistant_reasoning( ], ) - body = provider._build_request_body(request, thinking_enabled=False) + body = provider._build_request_body(request, reasoning=REASONING_OFF) - assert "private" not in str(body) - assert "visible" in str(body) + assert body["messages"][1]["content"] == ("\nprivate\n\n\nvisible") + assert body["extra_body"]["thinking_budget_tokens"] == 0 @pytest.mark.asyncio diff --git a/tests/providers/test_lmstudio.py b/tests/providers/test_lmstudio.py index 25c447b7a8e69019519f4a288055a7678d85fbda..6d0a70afa8ce0abff7c8f64c4201ee1b3a7f0822 100644 --- a/tests/providers/test_lmstudio.py +++ b/tests/providers/test_lmstudio.py @@ -7,10 +7,15 @@ import pytest from free_claude_code.application.errors import InvalidRequestError from free_claude_code.config.provider_catalog import LMSTUDIO_DEFAULT_BASE +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.lmstudio import LMStudioProvider from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import ( + REASONING_OFF, + REASONING_ON, + passthrough_rate_limiter, +) def make_request(**overrides): @@ -58,9 +63,32 @@ def test_build_request_body_basic(lmstudio_provider): assert body["messages"][0]["role"] == "system" +def test_adaptive_client_reasoning_uses_documented_named_effort(lmstudio_provider): + req = make_request() + + body = lmstudio_provider._build_request_body(req, reasoning=REASONING_ON) + + assert body["reasoning_effort"] == "high" + + +def test_exact_client_budget_is_not_derived_from_output_tokens(lmstudio_provider): + req = make_request(max_tokens=8192) + + body = lmstudio_provider._build_request_body( + req, + reasoning=ReasoningPolicy.on( + effort=ReasoningEffort.HIGH, + budget_tokens=1024, + ), + ) + + assert body["reasoning_tokens"] == 1024 + assert body["max_tokens"] == 8192 + + def test_build_request_body_never_replays_prior_thinking(lmstudio_provider): """Mistral-family templates have no assistant reasoning field; prior-turn - thinking must never be replayed regardless of the enable_thinking setting.""" + thinking must never be replayed regardless of current-turn reasoning policy.""" req = make_request( messages=[ {"role": "user", "content": "hi"}, @@ -83,15 +111,15 @@ def test_build_request_body_never_replays_prior_thinking(lmstudio_provider): assert "prior reasoning" not in str(body) -def test_preflight_builds_before_context_budget_and_preserves_false( +def test_preflight_builds_before_context_budget_and_preserves_policy( lmstudio_provider, ): request = make_request() calls: list[tuple[str, object]] = [] - def build(request_arg, thinking_enabled=None): + def build(request_arg, *, reasoning): assert request_arg is request - calls.append(("build", thinking_enabled)) + calls.append(("build", reasoning)) return {} def check_context(request_arg): @@ -106,9 +134,9 @@ def test_preflight_builds_before_context_budget_and_preserves_false( side_effect=check_context, ), ): - lmstudio_provider.preflight_stream(request, thinking_enabled=False) + lmstudio_provider.preflight_stream(request, reasoning=REASONING_OFF) - assert calls == [("build", False), ("context", request)] + assert calls == [("build", REASONING_OFF), ("context", request)] def test_preflight_conversion_failure_skips_context_budget(lmstudio_provider): @@ -124,7 +152,7 @@ def test_preflight_conversion_failure_skips_context_budget(lmstudio_provider): patch.object(lmstudio_provider, "_preflight_context_budget") as context, pytest.raises(InvalidRequestError, match="invalid request conversion"), ): - lmstudio_provider.preflight_stream(request, thinking_enabled=True) + lmstudio_provider.preflight_stream(request, reasoning=REASONING_ON) context.assert_not_called() diff --git a/tests/providers/test_minimax.py b/tests/providers/test_minimax.py index 75b6788dce7d3c2f8839480334ffe1e02dd1f2cc..2051565e9ba66cf9c5fc0434a333a3fc39019ec0 100644 --- a/tests/providers/test_minimax.py +++ b/tests/providers/test_minimax.py @@ -15,7 +15,12 @@ from free_claude_code.core.anthropic.stream_contracts import ( ) from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) class AsyncStream: @@ -43,7 +48,6 @@ def minimax_provider(): base_url=MINIMAX_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ), rate_limiter=passthrough_rate_limiter(), ) @@ -77,7 +81,7 @@ def test_init_uses_openai_chat_provider(minimax_provider): assert minimax_provider._provider_name == "MINIMAX" -def test_build_request_body_uses_adaptive_thinking_and_max_completion_tokens( +def test_build_request_body_requests_split_output_and_max_completion_tokens( minimax_provider, ): request = MessagesRequest.model_validate( @@ -95,25 +99,28 @@ def test_build_request_body_uses_adaptive_thinking_and_max_completion_tokens( } ) - body = minimax_provider._build_request_body(request) + body = minimax_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assert body["model"] == "MiniMax-M3" assert body["tools"][0]["function"]["name"] == "echo" assert body["max_completion_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS assert "max_tokens" not in body - assert body["extra_body"]["reasoning_split"] is True - assert body["extra_body"]["thinking"] == {"type": "adaptive"} + assert body["extra_body"] == {"reasoning_split": True} -def test_build_request_body_honors_no_thinking(minimax_provider): +def test_build_request_body_does_not_invent_unsupported_compute_control( + minimax_provider, +): request = MessagesRequest( model="MiniMax-M3", messages=[Message(role="user", content="Hello")], ) - body = minimax_provider._build_request_body(request, thinking_enabled=False) + body = minimax_provider._build_request_body(request, reasoning=REASONING_OFF) - assert body["extra_body"]["thinking"] == {"type": "disabled"} + assert body["extra_body"] == {"reasoning_split": True} @pytest.mark.asyncio diff --git a/tests/providers/test_mistral.py b/tests/providers/test_mistral.py index b59dfc77c948bc48868dc71ec651c3d3745085b7..efb863e898c70f89bb8a114b60baa670cacda1eb 100644 --- a/tests/providers/test_mistral.py +++ b/tests/providers/test_mistral.py @@ -12,7 +12,11 @@ from free_claude_code.core.failures import ExecutionFailure from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.mistral import MistralProvider from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + reasoning_for, +) def make_request(**overrides): @@ -26,7 +30,6 @@ def mistral_config(): base_url=MISTRAL_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -55,7 +58,7 @@ def test_default_base_url(): def test_build_request_body_basic(mistral_provider): """Basic request body conversion works for Mistral.""" req = make_request() - body = mistral_provider._build_request_body(req) + body = mistral_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["model"] == "devstral-small-latest" assert body["messages"][0]["role"] == "system" @@ -94,7 +97,7 @@ def test_build_request_body_replays_prior_thinking_as_mistral_chunks( ], ) - body = mistral_provider._build_request_body(req) + body = mistral_provider._build_request_body(req, reasoning=reasoning_for(req)) assistant = body["messages"][0] assert "reasoning_content" not in assistant @@ -125,7 +128,7 @@ def test_build_request_body_preserves_tools_tool_choice_and_params(mistral_provi stop_sequences=["STOP"], ) - body = mistral_provider._build_request_body(req) + body = mistral_provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["max_tokens"] == 100 assert body["temperature"] == 0.5 @@ -135,33 +138,30 @@ def test_build_request_body_preserves_tools_tool_choice_and_params(mistral_provi assert body["tool_choice"] == {"type": "function", "function": {"name": "echo"}} -def test_build_request_body_global_disable_blocks_reasoning_mapping(): - """Global disable disables reasoning replay in the converter.""" +def test_build_request_body_reasoning_off_uses_native_none(): provider = MistralProvider( ProviderConfig( api_key="test_mistral_key", base_url=MISTRAL_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) req = make_request() - body = provider._build_request_body(req) + body = provider._build_request_body(req, reasoning=REASONING_OFF) - assert "reasoning_effort" not in body + assert body["reasoning_effort"] == "none" assert all("reasoning_content" not in m for m in body.get("messages", [])) -def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking(): +def test_reasoning_off_keeps_replay_separate_from_new_turn_compute(): provider = MistralProvider( ProviderConfig( api_key="test_mistral_key", base_url=MISTRAL_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) @@ -178,10 +178,16 @@ def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking(): ], ) - body = provider._build_request_body(req) + body = provider._build_request_body(req, reasoning=REASONING_OFF) - assert "reasoning_effort" not in body - assert body["messages"][0]["content"] == "Visible." + assert body["reasoning_effort"] == "none" + assert body["messages"][0]["content"] == [ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Hidden."}], + }, + {"type": "text", "text": "Visible."}, + ] @pytest.mark.asyncio @@ -467,7 +473,7 @@ async def test_stream_response_suppresses_native_mistral_thinking_when_disabled( events = [ event async for event in mistral_provider.stream_response( - req, thinking_enabled=False + req, reasoning=REASONING_OFF ) ] @@ -529,7 +535,12 @@ async def test_stream_response_retries_without_mistral_reasoning_on_rejection( ) as mock_create: mock_create.side_effect = [error, mock_stream()] - events = [e async for e in mistral_provider.stream_response(req)] + events = [ + e + async for e in mistral_provider.stream_response( + req, reasoning=reasoning_for(req) + ) + ] assert mock_create.await_count == 2 first_call = mock_create.await_args_list[0].kwargs @@ -595,7 +606,12 @@ async def test_stream_response_reasoning_retry_preserves_visible_text_and_tools( ) as mock_create: mock_create.side_effect = [error, mock_stream()] - events = [e async for e in mistral_provider.stream_response(req)] + events = [ + e + async for e in mistral_provider.stream_response( + req, reasoning=reasoning_for(req) + ) + ] second_call = mock_create.await_args_list[1].kwargs assert second_call["messages"][0]["content"] == "Visible history." @@ -632,7 +648,12 @@ async def test_stream_response_retries_on_mistral_422_reasoning_rejection( ) as mock_create: mock_create.side_effect = [error, mock_stream()] - events = [e async for e in mistral_provider.stream_response(req)] + events = [ + e + async for e in mistral_provider.stream_response( + req, reasoning=reasoning_for(req) + ) + ] assert mock_create.await_count == 2 assert "reasoning_effort" not in mock_create.await_args_list[1].kwargs diff --git a/tests/providers/test_model_validation.py b/tests/providers/test_model_validation.py index 9b26273f06aca57cb2ce7777923b0a6c85bb2bba..d6eedd73c72c70addc240ff941c9eb343c31b580 100644 --- a/tests/providers/test_model_validation.py +++ b/tests/providers/test_model_validation.py @@ -16,6 +16,7 @@ from free_claude_code.config.provider_catalog import ( WAFER_DEFAULT_BASE, ) from free_claude_code.config.settings import Settings +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import BaseProvider, ProviderConfig from free_claude_code.providers.deepseek import DeepSeekProvider from free_claude_code.providers.model_listing import ModelListResponseError @@ -326,7 +327,10 @@ class FakeProvider(BaseProvider): self.cleaned = False def preflight_stream( - self, request: Any, *, thinking_enabled: bool | None = None + self, + request: Any, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> None: return None @@ -359,7 +363,7 @@ class FakeProvider(BaseProvider): input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> AsyncIterator[str]: if False: yield "" diff --git a/tests/providers/test_nvidia_nim.py b/tests/providers/test_nvidia_nim.py index 499f37a0f508d1132d687449e390d32d17e12c89..9b6f12f133786ecb8ffebdd5d4ac882486e0bfa4 100644 --- a/tests/providers/test_nvidia_nim.py +++ b/tests/providers/test_nvidia_nim.py @@ -1,5 +1,4 @@ import json -from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, patch import openai @@ -9,12 +8,18 @@ from httpx import Request, Response from free_claude_code.config.nim import NimSettings from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.reasoning import ReasoningPolicy from free_claude_code.providers.nvidia_nim import NvidiaNimProvider from free_claude_code.providers.nvidia_nim.tool_schema import ( NIM_TOOL_ARGUMENT_ALIASES_KEY, ) from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import ( + REASONING_OFF, + REASONING_ON, + passthrough_rate_limiter, + reasoning_for, +) def message(role, content): @@ -149,7 +154,7 @@ async def test_build_request_body(provider_config): rate_limiter=passthrough_rate_limiter(), ) req = make_request() - body = provider._build_request_body(req) + body = provider._build_request_body(req, reasoning=reasoning_for(req)) assert body["model"] == "test-model" assert body["temperature"] == 0.5 @@ -161,24 +166,27 @@ async def test_build_request_body(provider_config): ctk = body["extra_body"]["chat_template_kwargs"] assert ctk["thinking"] is True assert ctk["enable_thinking"] is True - assert ctk["reasoning_budget"] == body["max_tokens"] + assert "reasoning_budget" not in ctk assert "reasoning_budget" not in body["extra_body"] @pytest.mark.asyncio -async def test_build_request_body_omits_reasoning_when_globally_disabled( +async def test_build_request_body_encodes_explicit_reasoning_off( provider_config, ): provider = NvidiaNimProvider( - replace(provider_config, enable_thinking=False), + provider_config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter(), ) req = make_request() - body = provider._build_request_body(req) + body = provider._build_request_body(req, reasoning=REASONING_OFF) extra = body.get("extra_body", {}) - assert "chat_template_kwargs" not in extra + assert extra["chat_template_kwargs"] == { + "thinking": False, + "enable_thinking": False, + } assert "reasoning_budget" not in extra @@ -230,8 +238,8 @@ def test_preflight_and_build_request_issue_206_post_tool_text(nim_provider): ), ], ) - nim_provider.preflight_stream(req, thinking_enabled=False) - body = nim_provider._build_request_body(req, thinking_enabled=False) + nim_provider.preflight_stream(req, reasoning=REASONING_OFF) + body = nim_provider._build_request_body(req, reasoning=REASONING_OFF) assert "messages" in body assert any(m.get("role") == "tool" for m in body["messages"]) @@ -333,7 +341,7 @@ async def test_stream_response_thinking_reasoning_content(nim_provider): @pytest.mark.asyncio async def test_stream_response_suppresses_thinking_when_disabled(provider_config): provider = NvidiaNimProvider( - replace(provider_config, enable_thinking=False), + provider_config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter(), ) @@ -358,7 +366,9 @@ async def test_stream_response_suppresses_thinking_when_disabled(provider_config ) as mock_create: mock_create.return_value = mock_stream() - events = [e async for e in provider.stream_response(req)] + events = [ + e async for e in provider.stream_response(req, reasoning=REASONING_OFF) + ] event_text = "".join(events) assert "thinking_delta" not in event_text @@ -403,7 +413,9 @@ async def test_stream_response_retries_without_chat_template(provider_config): ) as mock_create: mock_create.side_effect = [first_error, mock_stream()] - events = [e async for e in provider.stream_response(req)] + events = [ + e async for e in provider.stream_response(req, reasoning=REASONING_ON) + ] assert mock_create.await_count == 2 @@ -414,7 +426,6 @@ async def test_stream_response_retries_without_chat_template(provider_config): assert first_extra["chat_template_kwargs"] == { "thinking": True, "enable_thinking": True, - "reasoning_budget": 100, } assert "reasoning_budget" not in first_extra @@ -459,7 +470,9 @@ async def test_stream_response_retries_without_chat_template_kwargs_issue_993( ) as mock_create: mock_create.side_effect = [first_error, mock_stream()] - events = [e async for e in provider.stream_response(req)] + events = [ + e async for e in provider.stream_response(req, reasoning=REASONING_ON) + ] assert mock_create.await_count == 2 @@ -470,7 +483,6 @@ async def test_stream_response_retries_without_chat_template_kwargs_issue_993( assert first_extra["chat_template_kwargs"] == { "thinking": True, "enable_thinking": True, - "reasoning_budget": 100, } second_extra = second_kwargs.get("extra_body") or {} assert "chat_template" not in second_extra @@ -752,15 +764,17 @@ async def test_stream_response_retries_without_reasoning_budget(nim_provider): ) as mock_create: mock_create.side_effect = [error, mock_stream()] - events = [e async for e in nim_provider.stream_response(req)] + events = [ + e + async for e in nim_provider.stream_response( + req, reasoning=ReasoningPolicy.on(budget_tokens=77) + ) + ] assert mock_create.await_count == 2 first_call = mock_create.await_args_list[0].kwargs second_call = mock_create.await_args_list[1].kwargs - assert ( - first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] - == first_call["max_tokens"] - ) + assert first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] == 77 assert "reasoning_budget" not in second_call["extra_body"] assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] assert second_call["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True @@ -796,15 +810,17 @@ async def test_stream_response_retries_without_budget_for_thinking_token_error( ) as mock_create: mock_create.side_effect = [error, mock_stream()] - events = [e async for e in nim_provider.stream_response(req)] + events = [ + e + async for e in nim_provider.stream_response( + req, reasoning=ReasoningPolicy.on(budget_tokens=77) + ) + ] assert mock_create.await_count == 2 first_call = mock_create.await_args_list[0].kwargs second_call = mock_create.await_args_list[1].kwargs - assert ( - first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] - == first_call["max_tokens"] - ) + assert first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] == 77 assert "reasoning_budget" not in second_call["extra_body"] assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] assert second_call["extra_body"]["chat_template_kwargs"]["thinking"] is True diff --git a/tests/providers/test_nvidia_nim_request.py b/tests/providers/test_nvidia_nim_request.py index 3fed38093b02ffd6fa223e17a3afc9a4b6a752a1..f9d95128081015465d257c63c7cb995f51d32f1b 100644 --- a/tests/providers/test_nvidia_nim_request.py +++ b/tests/providers/test_nvidia_nim_request.py @@ -8,6 +8,7 @@ import pytest from free_claude_code.config.nim import NimSettings from free_claude_code.core.anthropic import set_if_not_none from free_claude_code.core.anthropic.models import MessagesRequest, Tool +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.nvidia_nim.request_options import ( _set_extra, ) @@ -24,6 +25,7 @@ from free_claude_code.providers.nvidia_nim.tool_schema import ( nim_tool_argument_aliases_from_body, ) from tests.providers.request_factory import make_messages_request +from tests.providers.support import REASONING_OFF, REASONING_ON GREP_SCHEMA_FROM_SERVER_LOG: dict[str, Any] = { "type": "object", @@ -99,24 +101,34 @@ class TestSetExtra: class TestBuildRequestBody: + def test_named_effort_enables_boolean_chat_template_control(self, req): + policy = ReasoningPolicy(effort=ReasoningEffort.HIGH) + + body = build_request_body(req, NimSettings(), reasoning=policy) + + assert body["extra_body"]["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + } + def test_max_tokens_capped_by_nim(self, req): req.max_tokens = 100000 nim = NimSettings(max_tokens=4096) - body = build_request_body(req, nim, thinking_enabled=True) + body = build_request_body(req, nim, reasoning=REASONING_ON) assert body["max_tokens"] == 4096 def test_presence_penalty_included_when_nonzero(self, req): nim = NimSettings(presence_penalty=0.5) - body = build_request_body(req, nim, thinking_enabled=True) + body = build_request_body(req, nim, reasoning=REASONING_ON) assert body["presence_penalty"] == 0.5 def test_include_stop_str_in_output_not_sent(self, req): - body = build_request_body(req, NimSettings(), thinking_enabled=True) + body = build_request_body(req, NimSettings(), reasoning=REASONING_ON) assert "include_stop_str_in_output" not in body.get("extra_body", {}) def test_parallel_tool_calls_included(self, req): nim = NimSettings(parallel_tool_calls=False) - body = build_request_body(req, nim, thinking_enabled=True) + body = build_request_body(req, nim, reasoning=REASONING_ON) assert body["parallel_tool_calls"] is False def test_tool_schema_boolean_subschemas_are_removed_without_mutating_request( @@ -141,7 +153,7 @@ class TestBuildRequestBody: ) ] - body = build_request_body(req, NimSettings(), thinking_enabled=False) + body = build_request_body(req, NimSettings(), reasoning=REASONING_OFF) parameters = body["tools"][0]["function"]["parameters"] properties = parameters["properties"] @@ -169,7 +181,7 @@ class TestBuildRequestBody: ) ] - body = build_request_body(req, NimSettings(), thinking_enabled=False) + body = build_request_body(req, NimSettings(), reasoning=REASONING_OFF) parameters = body["tools"][0]["function"]["parameters"] properties = parameters["properties"] @@ -215,7 +227,7 @@ class TestBuildRequestBody: ) ] - body = build_request_body(req, NimSettings(), thinking_enabled=False) + body = build_request_body(req, NimSettings(), reasoning=REASONING_OFF) assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in body parameters = body["tools"][0]["function"]["parameters"] @@ -248,7 +260,7 @@ class TestBuildRequestBody: ) ] - body = build_request_body(req, NimSettings(), thinking_enabled=False) + body = build_request_body(req, NimSettings(), reasoning=REASONING_OFF) aliases = body[NIM_TOOL_ARGUMENT_ALIASES_KEY]["NotionLike"] parent = body["tools"][0]["function"]["parameters"]["properties"]["parent"] @@ -293,15 +305,34 @@ class TestBuildRequestBody: ) nim = NimSettings() - body = build_request_body(req, nim, thinking_enabled=True) + body = build_request_body(req, nim, reasoning=REASONING_ON) extra = body["extra_body"] assert extra["chat_template_kwargs"] == { "thinking": True, "enable_thinking": True, - "reasoning_budget": body["max_tokens"], } assert "reasoning_budget" not in extra + def test_canonicalization_removes_empty_client_reasoning_envelope(self): + req = make_messages_request( + model="test", + extra_body={ + "chat_template_kwargs": { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + }, + ) + + body = build_request_body( + req, + NimSettings(), + reasoning=ReasoningPolicy.provider_default(), + ) + + assert "chat_template_kwargs" not in body["extra_body"] + def test_clone_body_without_chat_template(self): body = { "model": "test", @@ -371,9 +402,12 @@ class TestBuildRequestBody: ) nim = NimSettings() - body = build_request_body(req, nim, thinking_enabled=False) + body = build_request_body(req, nim, reasoning=REASONING_OFF) extra = body.get("extra_body", {}) - assert "chat_template_kwargs" not in extra + assert extra["chat_template_kwargs"] == { + "thinking": False, + "enable_thinking": False, + } assert "reasoning_budget" not in extra def test_reasoning_budget_respects_existing_chat_template_kwargs(self): @@ -397,14 +431,14 @@ class TestBuildRequestBody: thinking=None, ) - body = build_request_body(req, NimSettings(), thinking_enabled=True) + body = build_request_body(req, NimSettings(), reasoning=REASONING_ON) assert body["extra_body"]["chat_template_kwargs"] == { - "enable_thinking": False, + "enable_thinking": True, "custom": "value", - "reasoning_budget": body["max_tokens"], + "thinking": True, } - def test_chat_template_fields_present_for_mistral_model(self): + def test_chat_template_fields_are_provider_wide(self): req = make_messages_request( model="mistralai/mixtral-8x7b-instruct-v0.1", messages=[{"role": "user", "content": "hi"}], @@ -421,12 +455,11 @@ class TestBuildRequestBody: ) nim = NimSettings(chat_template="custom_template") - body = build_request_body(req, nim, thinking_enabled=True) + body = build_request_body(req, nim, reasoning=REASONING_ON) extra = body.get("extra_body", {}) assert extra["chat_template_kwargs"] == { "thinking": True, "enable_thinking": True, - "reasoning_budget": body["max_tokens"], } assert extra["chat_template"] == "custom_template" @@ -447,7 +480,7 @@ class TestBuildRequestBody: ) nim = NimSettings() - body = build_request_body(req, nim, thinking_enabled=False) + body = build_request_body(req, nim, reasoning=REASONING_OFF) extra = body.get("extra_body", {}) for param in ( "thinking", @@ -457,6 +490,25 @@ class TestBuildRequestBody: "reasoning_effort", ): assert param not in extra + assert extra["chat_template_kwargs"] == { + "thinking": False, + "enable_thinking": False, + } + + def test_explicit_reasoning_budget_is_preserved_exactly(self): + req = make_messages_request(model="test", thinking=None) + + body = build_request_body( + req, + NimSettings(), + reasoning=ReasoningPolicy.on(budget_tokens=321), + ) + + assert body["extra_body"]["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 321, + } def test_assistant_thinking_blocks_removed_when_disabled(self): req = make_messages_request( @@ -482,7 +534,7 @@ class TestBuildRequestBody: thinking=None, ) - body = build_request_body(req, NimSettings(), thinking_enabled=False) + body = build_request_body(req, NimSettings(), reasoning=REASONING_OFF) assert "" not in body["messages"][0]["content"] assert "answer" in body["messages"][0]["content"] @@ -510,7 +562,7 @@ class TestBuildRequestBody: thinking=None, ) - body = build_request_body(req, NimSettings(), thinking_enabled=True) + body = build_request_body(req, NimSettings(), reasoning=REASONING_ON) assistant = body["messages"][0] assert assistant["reasoning_content"] == "secret" assert assistant["content"] == "answer" diff --git a/tests/providers/test_ollama.py b/tests/providers/test_ollama.py index ca246b03faab2ef8b1500063783a4c05c6073e86..4aee854ee23135c882c83409b2a80a09e45ec16f 100644 --- a/tests/providers/test_ollama.py +++ b/tests/providers/test_ollama.py @@ -15,7 +15,12 @@ from free_claude_code.core.anthropic.stream_contracts import ( from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) OLLAMA_MODEL = "llama3.1:8b" OLLAMA_CLOUD_MODEL = "qwen3-coder:480b" @@ -83,9 +88,12 @@ def test_build_request_body_uses_openai_chat_shape() -> None: assert "extra_body" not in body -def test_cloud_build_request_body_enables_ollama_reasoning() -> None: +def test_cloud_build_request_body_forwards_client_reasoning_effort() -> None: + request = make_messages_request( + OLLAMA_CLOUD_MODEL, output_config={"effort": "high"} + ) body = _cloud_provider()._build_request_body( - make_messages_request(OLLAMA_CLOUD_MODEL) + request, reasoning=reasoning_for(request) ) assert body["model"] == OLLAMA_CLOUD_MODEL @@ -121,7 +129,9 @@ def test_cloud_build_request_body_replays_thinking_in_ollama_reasoning_field() - ], ) - body = _cloud_provider()._build_request_body(request) + body = _cloud_provider()._build_request_body( + request, reasoning=reasoning_for(request) + ) assistant = next( message for message in body["messages"] if message["role"] == "assistant" @@ -130,13 +140,8 @@ def test_cloud_build_request_body_replays_thinking_in_ollama_reasoning_field() - assert "reasoning_content" not in assistant -@pytest.mark.parametrize( - ("provider", "expected_effort"), - [(_provider, None), (_cloud_provider, "none")], -) -def test_disabled_thinking_is_not_replayed_and_disables_ollama_reasoning( - provider, expected_effort -) -> None: +@pytest.mark.parametrize("provider", [_provider, _cloud_provider]) +def test_replay_is_independent_of_disabled_current_turn_reasoning(provider) -> None: request = make_messages_request( OLLAMA_CLOUD_MODEL, messages=[ @@ -151,16 +156,13 @@ def test_disabled_thinking_is_not_replayed_and_disables_ollama_reasoning( ], ) - body = provider()._build_request_body(request, thinking_enabled=False) + body = provider()._build_request_body(request, reasoning=REASONING_OFF) assistant = next( message for message in body["messages"] if message["role"] == "assistant" ) - if expected_effort is None: - assert "reasoning_effort" not in body - else: - assert body["reasoning_effort"] == expected_effort - assert "reasoning" not in assistant + assert body["reasoning_effort"] == "none" + assert assistant["reasoning"] == "Hidden plan." assert "reasoning_content" not in assistant assert assistant["content"] == "Visible answer." diff --git a/tests/providers/test_open_router.py b/tests/providers/test_open_router.py index 7e484ca923235f19d4bc61dfc0dbe1e188054a05..308b919c727457ce630f84c22e87e1bdf33d2b1f 100644 --- a/tests/providers/test_open_router.py +++ b/tests/providers/test_open_router.py @@ -16,7 +16,11 @@ from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.open_router import OpenRouterProvider from free_claude_code.providers.openai_chat import OpenAIChatProvider from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + reasoning_for, +) class AsyncStream: @@ -86,7 +90,7 @@ def test_build_request_body_uses_openai_chat_shape(open_router_provider): {"role": "user", "content": "Hello"}, ] assert body["max_tokens"] == 100 - assert body["extra_body"]["reasoning"] == {"enabled": True} + assert "extra_body" not in body def test_build_request_body_default_max_tokens(open_router_provider): @@ -107,30 +111,36 @@ def test_openrouter_extra_body_rejects_overriding_reserved_fields( def test_openrouter_extra_body_allows_provider_keys(open_router_provider): body = open_router_provider._build_request_body( make_request(extra_body={"transforms": ["no-web"], "plugins": []}), - thinking_enabled=False, + reasoning=REASONING_OFF, ) - assert body["extra_body"] == {"transforms": ["no-web"], "plugins": []} + assert body["extra_body"] == { + "transforms": ["no-web"], + "plugins": [], + "reasoning": {"enabled": False}, + } -def test_build_request_body_omits_reasoning_when_thinking_disabled( +def test_build_request_body_disables_reasoning_when_client_disables_it( open_router_provider, ): + request = make_request(thinking={"type": "disabled"}) body = open_router_provider._build_request_body( - make_request(thinking={"type": "disabled"}) + request, reasoning=reasoning_for(request) ) - assert "extra_body" not in body + assert body["extra_body"]["reasoning"] == {"enabled": False} def test_build_request_body_maps_thinking_budget_to_reasoning_max_tokens( open_router_provider, ): + request = make_request(thinking={"type": "enabled", "budget_tokens": 4096}) body = open_router_provider._build_request_body( - make_request(thinking={"type": "enabled", "budget_tokens": 4096}) + request, reasoning=reasoning_for(request) ) - assert body["extra_body"]["reasoning"] == {"enabled": True, "max_tokens": 4096} + assert body["extra_body"]["reasoning"] == {"max_tokens": 4096} def test_build_request_body_replays_openrouter_reasoning_details( @@ -156,7 +166,9 @@ def test_build_request_body_replays_openrouter_reasoning_details( } ) - body = open_router_provider._build_request_body(request) + body = open_router_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) assistant = next(msg for msg in body["messages"] if msg["role"] == "assistant") assert assistant["reasoning_details"] == [detail] diff --git a/tests/providers/test_openai_chat_output_cap.py b/tests/providers/test_openai_chat_output_cap.py index 9170330334d5fa197bf22687a7a7608246f50a51..125960dd6e54df862103c0e6f03a87de284dc16f 100644 --- a/tests/providers/test_openai_chat_output_cap.py +++ b/tests/providers/test_openai_chat_output_cap.py @@ -119,7 +119,6 @@ def groq_provider(): base_url=GROQ_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=False, ), rate_limiter=passthrough_rate_limiter(), ) diff --git a/tests/providers/test_openai_chat_reasoning.py b/tests/providers/test_openai_chat_reasoning.py new file mode 100644 index 0000000000000000000000000000000000000000..abcc6fc542042ec8bc8ba2a797ad3f21bf979ef7 --- /dev/null +++ b/tests/providers/test_openai_chat_reasoning.py @@ -0,0 +1,111 @@ +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy +from free_claude_code.providers.openai_chat.reasoning import ( + ChatTemplateReasoning, + LlamaCppReasoning, + NamedEffortReasoning, + ReasoningObject, + SplitReasoningOutput, + ThinkingObjectReasoning, +) + +_EFFORTS = ( + (ReasoningEffort.LOW, "low"), + (ReasoningEffort.HIGH, "high"), +) + + +def test_named_effort_encoder_translates_only_documented_values() -> None: + body: dict = {} + encoder = NamedEffortReasoning( + _EFFORTS, + disabled_value="none", + enabled_value="high", + ) + + encoder.encode(body, ReasoningPolicy.on(effort=ReasoningEffort.HIGH)) + + assert body == {"reasoning_effort": "high"} + + +def test_named_effort_encoder_uses_exact_budget_only_with_budget_field() -> None: + unsupported_body: dict = {} + supported_body: dict = {} + policy = ReasoningPolicy.on(budget_tokens=2048) + + NamedEffortReasoning(_EFFORTS, enabled_value="high").encode( + unsupported_body, policy + ) + NamedEffortReasoning( + _EFFORTS, + enabled_value="high", + budget_field="reasoning_tokens", + ).encode(supported_body, policy) + + assert unsupported_body == {"reasoning_effort": "high"} + assert supported_body == {"reasoning_tokens": 2048} + + +def test_reasoning_object_keeps_effort_budget_and_disable_shapes_exclusive() -> None: + effort_body: dict = {} + budget_body: dict = {} + off_body: dict = {} + encoder = ReasoningObject(_EFFORTS) + + encoder.encode( + effort_body, + ReasoningPolicy.on(effort=ReasoningEffort.HIGH), + ) + encoder.encode(budget_body, ReasoningPolicy.on(budget_tokens=512)) + encoder.encode(off_body, ReasoningPolicy.off()) + + assert effort_body == {"extra_body": {"reasoning": {"effort": "high"}}} + assert budget_body == {"extra_body": {"reasoning": {"max_tokens": 512}}} + assert off_body == {"extra_body": {"reasoning": {"enabled": False}}} + + +def test_thinking_object_leaves_provider_default_unmodified() -> None: + body: dict = {} + encoder = ThinkingObjectReasoning( + enabled={"type": "enabled"}, + disabled={"type": "disabled"}, + ) + + encoder.encode(body, ReasoningPolicy.provider_default()) + + assert body == {} + + +def test_chat_template_encoder_maps_named_effort_to_boolean_capability() -> None: + body: dict = {} + + ChatTemplateReasoning().encode( + body, + ReasoningPolicy( + effort=ReasoningEffort.MEDIUM, + ), + ) + + assert body == {"extra_body": {"chat_template_kwargs": {"thinking": True}}} + + +def test_llamacpp_encoder_forwards_only_exact_budget_or_off() -> None: + default_body: dict = {} + budget_body: dict = {} + off_body: dict = {} + encoder = LlamaCppReasoning() + + encoder.encode(default_body, ReasoningPolicy.on(effort=ReasoningEffort.HIGH)) + encoder.encode(budget_body, ReasoningPolicy.on(budget_tokens=256)) + encoder.encode(off_body, ReasoningPolicy.off()) + + assert default_body == {} + assert budget_body == {"extra_body": {"thinking_budget_tokens": 256}} + assert off_body == {"extra_body": {"thinking_budget_tokens": 0}} + + +def test_split_reasoning_output_does_not_invent_compute_control() -> None: + body: dict = {} + + SplitReasoningOutput().encode(body, ReasoningPolicy.off()) + + assert body == {"extra_body": {"reasoning_split": True}} diff --git a/tests/providers/test_openai_chat_usage.py b/tests/providers/test_openai_chat_usage.py index b05a12ebb3d4c1467087c0d6e227c1d8aa128aa9..109c2b7006a126664b3be3ed4e3c6fefdc8dd31f 100644 --- a/tests/providers/test_openai_chat_usage.py +++ b/tests/providers/test_openai_chat_usage.py @@ -8,14 +8,17 @@ import openai import pytest from httpx import Request, Response +from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, ) +from free_claude_code.providers.openai_chat.reasoning import NO_REASONING from free_claude_code.providers.openai_chat.usage import ( clone_without_stream_usage, is_stream_usage_rejection, @@ -36,13 +39,20 @@ class _UsageTestProvider(OpenAIChatProvider): rate_window=60, ), profile=OpenAIChatProfile( - OpenAIChatRequestPolicy(provider_name="USAGE_TEST") + OpenAIChatRequestPolicy( + provider_name="USAGE_TEST", + reasoning_replay=ReasoningReplayMode.DISABLED, + ), + NO_REASONING, ), rate_limiter=passthrough_rate_limiter(), ) def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: return {"model": request.model, "messages": [{"role": "user", "content": "x"}]} diff --git a/tests/providers/test_opencode.py b/tests/providers/test_opencode.py index 2b8ff76e87a44537035475939b638766347f6f8c..e457ea45308c570533448a704621802d63242893 100644 --- a/tests/providers/test_opencode.py +++ b/tests/providers/test_opencode.py @@ -2,10 +2,14 @@ from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.providers.base import ProviderConfig -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) -def test_build_request_body_preserves_empty_reasoning_content() -> None: +def test_build_request_body_omits_empty_reasoning_content() -> None: provider = profiled_provider( "opencode", ProviderConfig( @@ -13,7 +17,6 @@ def test_build_request_body_preserves_empty_reasoning_content() -> None: base_url="https://example.invalid/v1", rate_limit=1, rate_window=1, - enable_thinking=True, ), rate_limiter=passthrough_rate_limiter(), ) @@ -31,10 +34,9 @@ def test_build_request_body_preserves_empty_reasoning_content() -> None: } ) - body = provider._build_request_body(request) + body = provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["messages"][0] == { "role": "assistant", "content": "visible", - "reasoning_content": "", } diff --git a/tests/providers/test_preflight_contract.py b/tests/providers/test_preflight_contract.py index 764d2796d2ea854e557b3aefef13b0e664fbc91c..9890334df61a740eedf68fbd21d24c2f27f7e590 100644 --- a/tests/providers/test_preflight_contract.py +++ b/tests/providers/test_preflight_contract.py @@ -5,18 +5,22 @@ from collections.abc import AsyncIterator import pytest from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import BaseProvider, ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider class RecordingOpenAIProvider(OpenAIChatProvider): def __init__(self) -> None: - self.build_calls: list[tuple[MessagesRequest, bool | None]] = [] + self.build_calls: list[tuple[MessagesRequest, ReasoningPolicy]] = [] def _build_request_body( - self, request: MessagesRequest, thinking_enabled: bool | None = None + self, + request: MessagesRequest, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> dict: - self.build_calls.append((request, thinking_enabled)) + self.build_calls.append((request, reasoning)) return {} @@ -33,7 +37,7 @@ class ProviderWithoutPreflight(BaseProvider): input_tokens: int = 0, *, request_id: str | None = None, - thinking_enabled: bool | None = None, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> AsyncIterator[str]: if False: yield "" @@ -50,13 +54,13 @@ def test_openai_provider_owns_preflight() -> None: assert OpenAIChatProvider.preflight_stream is not BaseProvider.preflight_stream -def test_provider_preflight_calls_builder_and_preserves_false() -> None: +def test_provider_preflight_calls_builder_and_preserves_policy() -> None: provider = RecordingOpenAIProvider() request = MessagesRequest( model="test-model", messages=[Message(role="user", content="hello")], ) - provider.preflight_stream(request, thinking_enabled=False) + provider.preflight_stream(request, reasoning=ReasoningPolicy.off()) - assert provider.build_calls == [(request, False)] + assert provider.build_calls == [(request, ReasoningPolicy.off())] diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py index 7c11e38fa2029a341979d7fed03dc20858fc1caf..645e54293b5bbc8430b4a418bfac968876c6aa86 100644 --- a/tests/providers/test_provider_runtime.py +++ b/tests/providers/test_provider_runtime.py @@ -97,7 +97,6 @@ def _make_settings(**overrides): mock.http_read_timeout = 300.0 mock.http_write_timeout = 10.0 mock.http_connect_timeout = 10.0 - mock.enable_model_thinking = True mock.log_raw_sse_events = False mock.log_api_error_tracebacks = False mock.nim = NimSettings() diff --git a/tests/providers/test_sambanova.py b/tests/providers/test_sambanova.py index be20d0c0e0fa283a449e5be7dfb9bd4cce5da152..ddc859d6ff2fa63653a2f529a73d2790d7ce3ea2 100644 --- a/tests/providers/test_sambanova.py +++ b/tests/providers/test_sambanova.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from free_claude_code.config.provider_catalog import SAMBANOVA_DEFAULT_BASE +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from tests.providers.request_factory import make_messages_request from tests.providers.support import passthrough_rate_limiter, profiled_provider @@ -22,7 +23,6 @@ def sambanova_config(): base_url=SAMBANOVA_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) @@ -81,6 +81,27 @@ def test_build_request_body_preserves_caller_extra_body(sambanova_provider): assert eb.get("metadata") == {"user": "u1"} +@pytest.mark.parametrize( + ("reasoning", "expected"), + ( + (ReasoningPolicy.provider_default(), None), + (ReasoningPolicy.off(), None), + (ReasoningPolicy.on(effort=ReasoningEffort.LOW), "low"), + (ReasoningPolicy.on(effort=ReasoningEffort.XHIGH), "high"), + (ReasoningPolicy.on(), "medium"), + ), +) +def test_build_request_body_uses_only_documented_reasoning_efforts( + sambanova_provider, reasoning, expected +): + body = sambanova_provider._build_request_body( + make_request(), + reasoning=reasoning, + ) + + assert body.get("reasoning_effort") == expected + + @pytest.mark.asyncio async def test_stream_response_text(sambanova_provider): """Text content deltas are emitted as text blocks.""" diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py index c033b4643d4222387e8bddd044b3593942768f53..7d81932bdd8d2314832b21fe254d7ed3a16a80e7 100644 --- a/tests/providers/test_streaming_errors.py +++ b/tests/providers/test_streaming_errors.py @@ -17,6 +17,7 @@ from free_claude_code.core.anthropic.streaming import ( make_text_recovery_body, ) from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.nvidia_nim import NvidiaNimProvider from free_claude_code.providers.openai_chat.provider import ( @@ -32,7 +33,7 @@ from free_claude_code.providers.stream_recovery import ( TruncatedProviderStreamError, ) from tests.providers.request_factory import make_messages_request -from tests.providers.support import passthrough_rate_limiter +from tests.providers.support import REASONING_OFF, passthrough_rate_limiter class AsyncStreamMock: @@ -84,22 +85,6 @@ def _make_tool_assembler(provider: NvidiaNimProvider) -> OpenAIToolCallAssembler ) -def _make_provider_with_thinking_enabled(enabled: bool): - """Create a provider instance with thinking explicitly enabled or disabled.""" - config = ProviderConfig( - api_key="test_key", - base_url="https://test.api.nvidia.com/v1", - rate_limit=10, - rate_window=60, - enable_thinking=enabled, - ) - return NvidiaNimProvider( - config, - nim_settings=NimSettings(), - rate_limiter=passthrough_rate_limiter(), - ) - - def _make_request(model: str = "test-model", stream: bool = True, **overrides: object): """Create a concrete request matching the original streaming-test defaults.""" request_overrides: dict[str, object] = { @@ -128,7 +113,7 @@ def _make_stream_runner( request=request or _make_request(), input_tokens=0, request_id=request_id, - thinking_enabled=None, + reasoning=DEFAULT_REASONING_POLICY, ) @@ -163,9 +148,14 @@ def _make_tool_calls_chunk(*, name: str, arguments: str, tool_id: str, index: in return _make_chunk(tool_calls=[tc]) -async def _collect_stream(provider, request): +async def _collect_stream( + provider, + request, + *, + reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, +): """Collect all SSE events from a stream.""" - return [e async for e in provider.stream_response(request)] + return [e async for e in provider.stream_response(request, reasoning=reasoning)] async def _collect_stream_error(provider, request, **kwargs) -> ExecutionFailure: @@ -422,7 +412,7 @@ class TestStreamingExceptionHandling: NIM / some templates may emit no main ``content``; a minimal text block matches the empty-body placeholder and helps clients that expect a text segment. """ - provider = _make_provider_with_thinking_enabled(True) + provider = _make_provider() request = _make_request() chunk1 = _make_chunk(reasoning_content="reasoning only from provider") chunk2 = _make_chunk(finish_reason="stop") @@ -556,7 +546,7 @@ class TestStreamingExceptionHandling: @pytest.mark.asyncio async def test_stream_with_reasoning_content_suppressed_when_disabled(self): """reasoning deltas are stripped while normal text still streams.""" - provider = _make_provider_with_thinking_enabled(False) + provider = _make_provider() request = _make_request() chunk1 = _make_chunk(reasoning_content="I think...") @@ -578,7 +568,7 @@ class TestStreamingExceptionHandling: return_value=False, ), ): - events = await _collect_stream(provider, request) + events = await _collect_stream(provider, request, reasoning=REASONING_OFF) event_text = "".join(events) assert "thinking_delta" not in event_text @@ -843,7 +833,7 @@ class TestStreamingExceptionHandling: @pytest.mark.asyncio async def test_disabled_thinking_recovery_discards_reasoning(self): - provider = _make_provider_with_thinking_enabled(False) + provider = _make_provider() request = _make_request() initial_stream = AsyncStreamMock([_make_chunk(content="hello")]) recovery_stream = AsyncStreamMock( @@ -860,7 +850,7 @@ class TestStreamingExceptionHandling: new_callable=AsyncMock, side_effect=[initial_stream, recovery_stream], ): - events = await _collect_stream(provider, request) + events = await _collect_stream(provider, request, reasoning=REASONING_OFF) parsed = parse_sse_text("".join(events)) text = "".join( @@ -996,7 +986,7 @@ class TestStreamingExceptionHandling: ledger=ledger, error=TimeoutError("cutoff"), tool_argument_alias_buffers={}, - thinking_enabled=True, + output_reasoning=True, ) assert events is not None diff --git a/tests/providers/test_vercel.py b/tests/providers/test_vercel.py index d3c700df44b5ad6fd4a0497ccbfd52b6fda15aa1..20f455460e1c94509710733e08f53d41746f2696 100644 --- a/tests/providers/test_vercel.py +++ b/tests/providers/test_vercel.py @@ -22,7 +22,6 @@ def vercel_config(): base_url=VERCEL_AI_GATEWAY_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ) diff --git a/tests/providers/test_wafer.py b/tests/providers/test_wafer.py index 96896a3c9ab65a8fe0bc712d75c8017253d2f939..4877c7364c8219ecdf9101d64069442e006ec5d3 100644 --- a/tests/providers/test_wafer.py +++ b/tests/providers/test_wafer.py @@ -1,6 +1,5 @@ """Tests for the Wafer OpenAI-chat provider.""" -from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -10,27 +9,14 @@ from free_claude_code.config.provider_catalog import WAFER_DEFAULT_BASE from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import ( - OPENAI_CHAT_PROFILES, OpenAIChatProvider, ) -from free_claude_code.providers.rate_limit import ProviderRateLimiter -from tests.providers.support import passthrough_rate_limiter, profiled_provider - - -class CountingWaferProvider(OpenAIChatProvider): - def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): - super().__init__( - config, - profile=OPENAI_CHAT_PROFILES["wafer"], - rate_limiter=rate_limiter, - ) - self.thinking_checks = 0 - - def _is_thinking_enabled( - self, request: Any, thinking_enabled: bool | None = None - ) -> bool: - self.thinking_checks += 1 - return super()._is_thinking_enabled(request, thinking_enabled) +from tests.providers.support import ( + REASONING_OFF, + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) @pytest.fixture @@ -79,12 +65,12 @@ def test_build_request_body_openai_shape_and_defaults(wafer_provider): } ) - body = wafer_provider._build_request_body(request) + body = wafer_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["model"] == "DeepSeek-V4-Pro" assert body["messages"][0] == {"role": "user", "content": "Hello"} assert body["tools"][0]["function"]["name"] == "echo" - assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["reasoning_effort"] == "high" assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -96,9 +82,9 @@ def test_build_request_body_honors_effective_no_thinking(wafer_provider): } ) - body = wafer_provider._build_request_body(request, thinking_enabled=False) + body = wafer_provider._build_request_body(request, reasoning=REASONING_OFF) - assert body["extra_body"]["thinking"] == {"type": "disabled"} + assert body["reasoning_effort"] == "none" def test_build_request_body_preserves_request_disabled_thinking(wafer_provider): @@ -110,16 +96,14 @@ def test_build_request_body_preserves_request_disabled_thinking(wafer_provider): } ) - body = wafer_provider._build_request_body(request, thinking_enabled=True) + body = wafer_provider._build_request_body(request, reasoning=reasoning_for(request)) - assert body["extra_body"]["thinking"] == {"type": "disabled"} + assert body["reasoning_effort"] == "none" -def test_build_request_body_resolves_thinking_once(wafer_config): - provider = CountingWaferProvider( - wafer_config, - rate_limiter=passthrough_rate_limiter(), - ) +def test_build_request_body_uses_resolved_policy_without_inspecting_model( + wafer_provider, +): request = MessagesRequest.model_validate( { "model": "DeepSeek-V4-Pro", @@ -127,10 +111,9 @@ def test_build_request_body_resolves_thinking_once(wafer_config): } ) - body = provider._build_request_body(request, thinking_enabled=False) + body = wafer_provider._build_request_body(request, reasoning=REASONING_OFF) - assert body["extra_body"]["thinking"] == {"type": "disabled"} - assert provider.thinking_checks == 1 + assert body["reasoning_effort"] == "none" @pytest.mark.asyncio diff --git a/tests/providers/test_zai.py b/tests/providers/test_zai.py index 364ee6bae9b45ba4d828aaf6c4be18c181d4acf7..d0fa9d11c3427bd36f84fa89dc23b5146c25e945 100644 --- a/tests/providers/test_zai.py +++ b/tests/providers/test_zai.py @@ -10,7 +10,11 @@ from free_claude_code.config.provider_catalog import ZAI_DEFAULT_BASE from free_claude_code.core.anthropic.models import Message, MessagesRequest from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.openai_chat import OpenAIChatProvider -from tests.providers.support import passthrough_rate_limiter, profiled_provider +from tests.providers.support import ( + passthrough_rate_limiter, + profiled_provider, + reasoning_for, +) @pytest.fixture @@ -22,7 +26,6 @@ def zai_provider(): base_url=ZAI_DEFAULT_BASE, rate_limit=10, rate_window=60, - enable_thinking=True, ), rate_limiter=passthrough_rate_limiter(), ) @@ -35,13 +38,16 @@ def test_init_uses_openai_chat_coding_endpoint(zai_provider): def test_build_request_body_openai_chat(zai_provider): - request = MessagesRequest( - model="glm-5.2", - max_tokens=100, - messages=[Message(role="user", content="Hello")], + request = MessagesRequest.model_validate( + { + "model": "glm-5.2", + "max_tokens": 100, + "messages": [Message(role="user", content="Hello")], + "thinking": {"type": "enabled"}, + } ) - body = zai_provider._build_request_body(request) + body = zai_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["model"] == "glm-5.2" assert body["max_tokens"] == 100 @@ -58,7 +64,7 @@ def test_build_request_body_default_max_tokens(zai_provider): messages=[Message(role="user", content="x")], ) - body = zai_provider._build_request_body(request) + body = zai_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -73,7 +79,7 @@ def test_build_request_body_rejects_caller_extra_body(zai_provider): ) with pytest.raises(InvalidRequestError, match=r"Z\.ai Chat Completions"): - zai_provider._build_request_body(request) + zai_provider._build_request_body(request, reasoning=reasoning_for(request)) def test_build_request_body_disables_zai_thinking(zai_provider): @@ -85,7 +91,7 @@ def test_build_request_body_disables_zai_thinking(zai_provider): } ) - body = zai_provider._build_request_body(request) + body = zai_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["extra_body"]["thinking"] == {"type": "disabled"} @@ -104,13 +110,10 @@ def test_build_request_body_replays_prior_reasoning_content(zai_provider): } ) - body = zai_provider._build_request_body(request) + body = zai_provider._build_request_body(request, reasoning=reasoning_for(request)) assert body["messages"][0]["reasoning_content"] == "prior" - assert body["extra_body"]["thinking"] == { - "type": "enabled", - "clear_thinking": False, - } + assert "extra_body" not in body @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index c4c1951672eaed762e7d0d2c9c8e5d60b7e0ab8d..1bed0e4a9c416fff5f510297dd1c3a3c34fb4a28 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "4.7.3" +version = "4.8.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },