Skip to content

Commit 33fce8e

Browse files
committed
fix: gate reasoning fields on model.reasoning inside apply_reasoning_control
Consolidates the model.reasoning check that each provider re-implemented inconsistently into apply_reasoning_control itself: - Anthropic no longer sends thinking:enabled to non-reasoning models (the deleted clamp_thinking_level used to guarantee this; nothing replaced it). - Hybrid/off-mode payloads (e.g. Qwen's enable_thinking:false) now apply regardless of model.reasoning, restoring the pre-refactor behavior. - Chat Completions no longer injects a default temperature for reasoning models (o-series/gpt-5 reject non-default temperature), matching the guard already present in the Responses provider. - Anthropic's built-in profile now has budgets for effort="minimal"/"max" so requesting them doesn't produce thinking:enabled with no budget_tokens. - OpenAI's effort="max" now maps to the real "xhigh" tier instead of the invalid literal "max". - get_capability_profile() drops unreachable fallback branches and the magic "anthropic.messages.legacy_budget" string in favor of a small alias table and a direct ("anthropic", "messages") call. - The postgres schema-mismatch hint now names the actual upgrade_vN_to_vM_op helper(s) needed instead of hardcoding the v3->v4 helper. - Docs updated for the ReasoningControl/ReasoningCapability rename.
1 parent d89c4a8 commit 33fce8e

17 files changed

Lines changed: 358 additions & 143 deletions

File tree

cubepi/checkpointer/postgres/checkpointer.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ def _run_key(run_id: str | None) -> str:
4444
return run_id or ""
4545

4646

47+
def _schema_mismatch_hint(actual: int, expected: int) -> str:
48+
"""Build the operator-facing hint for a schema-version mismatch.
49+
50+
Names the actual upgrade_vN_to_vM_op() helper(s) needed to close the gap
51+
between ``actual`` and ``expected``, rather than hardcoding one version
52+
transition, so the hint stays correct as EXPECTED_SCHEMA_VERSION grows.
53+
"""
54+
steps = ", ".join(
55+
f"upgrade_v{v}_to_v{v + 1}_op()" for v in range(actual, expected)
56+
)
57+
return (
58+
"cubepi was upgraded but host alembic is behind. "
59+
f"Generate a new alembic revision that calls {steps} + "
60+
"write_schema_version_op() (see "
61+
"cubepi.checkpointer.postgres.alembic_helpers) and run "
62+
"`alembic upgrade head` against this database."
63+
)
64+
65+
4766
def _serialize_structured_value(value: StructuredValue) -> str:
4867
return _STRUCTURED_VALUE_ADAPTER.dump_json(value).decode("utf-8")
4968

@@ -131,13 +150,7 @@ async def _verify_schema(self) -> None:
131150
raise CubepiSchemaMismatch(
132151
expected=EXPECTED_SCHEMA_VERSION,
133152
actual=row["version"],
134-
hint=(
135-
"cubepi was upgraded but host alembic is behind. "
136-
"Generate a new alembic revision that calls "
137-
"add_run_id_column_op() + write_schema_version_op() "
138-
"(see cubepi.checkpointer.postgres.alembic_helpers) "
139-
"and run `alembic upgrade head` against this database."
140-
),
153+
hint=_schema_mismatch_hint(row["version"], EXPECTED_SCHEMA_VERSION),
141154
)
142155

143156
async def load(self, thread_id: str) -> CheckpointData | None:

cubepi/providers/anthropic.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,12 @@ def __init__(
9191
cache_policy or DefaultCacheMarkerPolicy()
9292
)
9393
# Anthropic always runs the capability path; capability=None falls back
94-
# to _ANTHROPIC_DEFAULT_CAPABILITY which mirrors legacy wire bytes.
94+
# to the built-in Anthropic Messages profile, which mirrors legacy
95+
# wire bytes.
9596
self._capability: CapabilityDescriptor = (
9697
capability
9798
if capability is not None
98-
else get_capability_profile("anthropic.messages.legacy_budget")
99+
else get_capability_profile("anthropic", "messages")
99100
)
100101
self._model_overrides: dict[str, CapabilityDescriptor] = (
101102
model_capability_overrides or {}

cubepi/providers/capability.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,17 @@ def apply_reasoning_control(
135135
*,
136136
model: Model | None = None,
137137
) -> list[CapabilityWarning]:
138-
"""Apply provider-independent reasoning controls to a provider payload."""
139-
del model
140-
138+
"""Apply provider-independent reasoning controls to a provider payload.
139+
140+
When ``model.reasoning`` is False, any requested mode other than "off" is
141+
treated as "off" — the model can't reason, so no effort/summary/include
142+
fields are written and only the "off" mode payload (if any) applies. This
143+
is what lets a hybrid model's off-payload (e.g. Qwen's
144+
``enable_thinking: false``) keep firing regardless of ``model.reasoning``,
145+
while never sending enable/effort/summary fields to a model that can't use
146+
them. Callers should invoke this unconditionally rather than re-deriving
147+
their own ``model.reasoning`` gate.
148+
"""
141149
reasoning = (
142150
capability.reasoning
143151
if isinstance(capability, CapabilityDescriptor)
@@ -146,35 +154,44 @@ def apply_reasoning_control(
146154
if reasoning is None:
147155
return []
148156

157+
model_supports_reasoning = model is None or model.reasoning
158+
effective_mode = control.mode if model_supports_reasoning else "off"
159+
149160
warnings: list[CapabilityWarning] = []
150-
mode_payload = reasoning.mode_payloads.get(control.mode)
161+
mode_payload = reasoning.mode_payloads.get(effective_mode)
151162
if mode_payload is not None:
152163
merge_capability_payload(kwargs, mode_payload)
153164
else:
154-
_handle_unsupported_mode(reasoning, control.mode, warnings)
165+
_handle_unsupported_mode(reasoning, effective_mode, warnings)
155166

156167
if (
157-
reasoning.effort_path is not None
158-
and (control.mode != "off" or reasoning.apply_effort_when_off)
168+
model_supports_reasoning
169+
and reasoning.effort_path is not None
170+
and (effective_mode != "off" or reasoning.apply_effort_when_off)
159171
):
160172
effort = reasoning.effort_values.get(control.effort)
161173
if effort is not None:
162174
_write_dotted_path(kwargs, reasoning.effort_path, effort)
163175

164-
if reasoning.summary_path is not None:
176+
if (
177+
model_supports_reasoning
178+
and reasoning.summary_path is not None
179+
and effective_mode != "off"
180+
):
165181
summary = reasoning.summary_values.get(control.summary)
166182
if summary is not None:
167183
_write_dotted_path(kwargs, reasoning.summary_path, summary)
168184

169-
for key in (
170-
"always",
171-
f"mode:{control.mode}",
172-
f"effort:{control.effort}",
173-
f"summary:{control.summary}",
174-
):
175-
patch = reasoning.include_payloads.get(key)
176-
if patch is not None:
177-
merge_capability_payload(kwargs, patch)
185+
if model_supports_reasoning:
186+
for key in (
187+
"always",
188+
f"mode:{effective_mode}",
189+
f"effort:{control.effort}",
190+
f"summary:{control.summary}",
191+
):
192+
patch = reasoning.include_payloads.get(key)
193+
if patch is not None:
194+
merge_capability_payload(kwargs, patch)
178195

179196
return warnings
180197

cubepi/providers/openai.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ async def _produce() -> None:
151151
# temperature or max_tokens. Spec §3.5.
152152
cap = self._resolve_capability(model.id)
153153
if self._cap_active or model.reasoning:
154-
kwargs.setdefault("temperature", model.temperature)
154+
# Reasoning models reject a non-default temperature, so
155+
# only setdefault it for models that don't reason.
156+
if not model.reasoning:
157+
kwargs.setdefault("temperature", model.temperature)
155158
# Don't inject a default max_tokens when the caller already
156159
# set the renamed target field (e.g. max_completion_tokens
157160
# via on_payload).
@@ -160,8 +163,7 @@ async def _produce() -> None:
160163
apply_temperature(kwargs, cap.temperature)
161164
if cap.max_tokens_field != "max_tokens" and "max_tokens" in kwargs:
162165
kwargs[cap.max_tokens_field] = kwargs.pop("max_tokens")
163-
if model.reasoning:
164-
apply_reasoning_control(kwargs, cap, opts.reasoning, model=model)
166+
apply_reasoning_control(kwargs, cap, opts.reasoning, model=model)
165167

166168
# Fire request listeners AFTER all kwargs mutations so observers
167169
# see the final wire payload (including extra_body merges,

cubepi/providers/openai_responses.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,7 @@ async def _produce() -> None:
149149
kwargs.setdefault("max_output_tokens", model.max_tokens)
150150
cap = self._resolve_capability(model.id)
151151
apply_temperature(kwargs, cap.temperature)
152-
if model.reasoning:
153-
apply_reasoning_control(kwargs, cap, opts.reasoning, model=model)
152+
apply_reasoning_control(kwargs, cap, opts.reasoning, model=model)
154153
else:
155154
if model.max_tokens:
156155
kwargs.setdefault("max_output_tokens", model.max_tokens)

cubepi/providers/reasoning_profiles.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"low": "low",
1212
"medium": "medium",
1313
"high": "high",
14-
"max": "max",
14+
# OpenAI has no "max" effort value; "xhigh" is its highest tier (mirrors
15+
# the legacy _THINKING_TO_EFFORT mapping's "xhigh" -> "xhigh").
16+
"max": "xhigh",
1517
}
1618

1719
_OPENAI_SUMMARY_VALUES: dict[ReasoningSummary, Any] = {
@@ -22,6 +24,11 @@
2224
}
2325

2426

27+
_API_ALIASES: dict[tuple[str, str], tuple[str, str]] = {
28+
("openai", "openai-completions"): ("openai", "chat_completions"),
29+
}
30+
31+
2532
def get_capability_profile(provider: str, api: str | None = None) -> CapabilityDescriptor:
2633
"""Return the built-in capability profile for a provider/API pair."""
2734

@@ -31,17 +38,10 @@ def get_capability_profile(provider: str, api: str | None = None) -> CapabilityD
3138
if api is None:
3239
return CapabilityDescriptor()
3340

34-
key = (provider_key, api)
41+
key = _API_ALIASES.get((provider_key, api), (provider_key, api))
3542
profile = _PROFILES.get(key)
3643
if profile is not None:
3744
return profile.model_copy(deep=True)
38-
39-
if provider_key == "openai" and api in {"chat_completions", "openai-completions"}:
40-
return _PROFILES[("openai", "chat_completions")].model_copy(deep=True)
41-
if provider_key == "openai" and api == "responses":
42-
return _PROFILES[("openai", "responses")].model_copy(deep=True)
43-
if provider_key == "anthropic":
44-
return _PROFILES[("anthropic", "messages")].model_copy(deep=True)
4545
return CapabilityDescriptor()
4646

4747

@@ -80,9 +80,15 @@ def get_capability_profile(provider: str, api: str | None = None) -> CapabilityD
8080
},
8181
effort_path="thinking.budget_tokens",
8282
effort_values={
83+
# Anthropic requires budget_tokens >= 1024 for extended
84+
# thinking; "minimal" uses that floor.
85+
"minimal": 1024,
8386
"low": 2048,
8487
"medium": 8192,
8588
"high": 16384,
89+
# No tier above "high" in the legacy budget scale (the old
90+
# ThinkingLevel="xhigh" clamped down to "high"'s budget).
91+
"max": 16384,
8692
},
8793
apply_effort_when_off=False,
8894
unsupported_mode_policy="skip",

tests/checkpointer/test_postgres_schema.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import asyncpg
22
import pytest
33

4+
from cubepi.checkpointer.postgres.checkpointer import _schema_mismatch_hint
45
from cubepi.checkpointer.postgres.models import EXPECTED_SCHEMA_VERSION
56

67

78
def test_expected_schema_version_is_5():
89
assert EXPECTED_SCHEMA_VERSION == 5
910

1011

12+
def test_schema_mismatch_hint_names_the_v4_to_v5_helper():
13+
hint = _schema_mismatch_hint(actual=4, expected=5)
14+
assert "upgrade_v4_to_v5_op()" in hint
15+
assert "add_run_id_column_op" not in hint
16+
17+
18+
def test_schema_mismatch_hint_names_every_step_across_multiple_versions():
19+
hint = _schema_mismatch_hint(actual=3, expected=5)
20+
assert "upgrade_v3_to_v4_op()" in hint
21+
assert "upgrade_v4_to_v5_op()" in hint
22+
23+
1124
@pytest.mark.asyncio
1225
async def test_cubepi_runs_table_present(pg_v4_dsn):
1326
conn = await asyncpg.connect(pg_v4_dsn)

tests/providers/test_openai_capability.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,18 @@ async def test_openai_chat_off_writes_minimal_effort() -> None:
171171
assert payload["reasoning_effort"] == "minimal"
172172

173173

174+
@pytest.mark.asyncio
175+
async def test_openai_chat_reasoning_model_does_not_get_default_temperature() -> None:
176+
"""Reasoning models on chat completions reject a non-default temperature;
177+
a legacy caller with no explicit capability must not have one injected."""
178+
payload = await _capture_payload_openai(
179+
OpenAIProvider(api_key="x", base_url="http://example"),
180+
_model(reasoning=True, temperature=0.7),
181+
)
182+
183+
assert "temperature" not in payload
184+
185+
174186
@pytest.mark.asyncio
175187
async def test_temperature_ignored_strips_field():
176188
cap = CapabilityDescriptor(temperature=TemperatureSpec(mode="ignored"))

tests/providers/test_reasoning_capability.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,104 @@ def test_lint_warns_for_top_level_thinking_on_openai_chat():
9292
assert isinstance(warnings[0], CapabilityWarning)
9393
assert warnings[0].code == "openai_chat_top_level_thinking"
9494
assert "extra_body.thinking" in warnings[0].message
95+
96+
97+
def test_apply_reasoning_clamps_mode_to_off_for_non_reasoning_model():
98+
"""A non-reasoning model must never receive an enabled mode payload,
99+
mirroring the clamping the deleted clamp_thinking_level used to do."""
100+
cap = get_capability_profile("anthropic", "messages")
101+
payload: dict = {}
102+
103+
apply_reasoning_control(
104+
payload,
105+
cap,
106+
ReasoningControl(mode="on", effort="medium"),
107+
model=Model(id="claude-haiku", provider_id="anthropic", reasoning=False),
108+
)
109+
110+
assert payload == {"thinking": {"type": "disabled"}}
111+
112+
113+
def test_apply_reasoning_off_payload_applies_regardless_of_model_reasoning():
114+
"""Hybrid models (e.g. Qwen) must still get their off-mode payload even
115+
when registered with reasoning=False."""
116+
cap = CapabilityDescriptor(
117+
reasoning=ReasoningCapability(
118+
mode_payloads={
119+
"off": {"extra_body": {"enable_thinking": False}},
120+
"on": {"extra_body": {"enable_thinking": True}},
121+
}
122+
)
123+
)
124+
payload: dict = {}
125+
126+
apply_reasoning_control(
127+
payload,
128+
cap,
129+
ReasoningControl(mode="off"),
130+
model=Model(id="qwen", provider_id="test", reasoning=False),
131+
)
132+
133+
assert payload == {"extra_body": {"enable_thinking": False}}
134+
135+
136+
def test_apply_reasoning_skips_effort_for_non_reasoning_model_even_with_apply_when_off():
137+
"""model.reasoning=False must suppress effort writes entirely, even when
138+
the capability sets apply_effort_when_off=True."""
139+
cap = CapabilityDescriptor(
140+
reasoning=ReasoningCapability(
141+
mode_payloads={"on": {"reasoning": {"effort": "low"}}},
142+
effort_path="reasoning.effort",
143+
effort_values={"medium": "medium"},
144+
)
145+
)
146+
payload: dict = {}
147+
148+
apply_reasoning_control(
149+
payload,
150+
cap,
151+
ReasoningControl(mode="on", effort="medium"),
152+
model=Model(id="gpt-4o", provider_id="test", reasoning=False),
153+
)
154+
155+
assert payload == {}
156+
157+
158+
def test_anthropic_profile_has_budgets_for_minimal_and_max_effort():
159+
cap = get_capability_profile("anthropic", "messages")
160+
payload: dict = {}
161+
162+
apply_reasoning_control(
163+
payload,
164+
cap,
165+
ReasoningControl(mode="on", effort="max"),
166+
model=Model(id="claude-opus", provider_id="anthropic", reasoning=True),
167+
)
168+
169+
assert payload["thinking"]["type"] == "enabled"
170+
assert payload["thinking"]["budget_tokens"] > 0
171+
172+
173+
def test_openai_effort_max_maps_to_xhigh_not_invalid_max_value():
174+
cap = get_capability_profile("openai", "responses")
175+
payload: dict = {}
176+
177+
apply_reasoning_control(
178+
payload,
179+
cap,
180+
ReasoningControl(mode="on", effort="max"),
181+
model=Model(id="gpt-5", provider_id="openai", reasoning=True),
182+
)
183+
184+
assert payload["reasoning"]["effort"] == "xhigh"
185+
186+
187+
def test_get_capability_profile_resolves_openai_completions_alias():
188+
direct = get_capability_profile("openai", "chat_completions")
189+
aliased = get_capability_profile("openai", "openai-completions")
190+
assert aliased == direct
191+
192+
193+
def test_get_capability_profile_unknown_pair_returns_empty_descriptor():
194+
cap = get_capability_profile("some-vendor", "some-api")
195+
assert cap == CapabilityDescriptor()

website/docs/getting-started/core-concepts.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ class Provider(Protocol):
8181
options: StreamOptions | None = None,
8282
max_output_tokens: int | None = None,
8383
temperature: float | None = None,
84-
thinking: ThinkingLevel | None = None,
85-
thinking_budgets: ThinkingBudgets | None = None,
84+
reasoning: ReasoningControl | None = None,
8685
) -> AssistantMessage: ...
8786
```
8887

@@ -92,7 +91,7 @@ yields `StreamEvent`s and exposes the final `AssistantMessage` via
9291
the final message directly; `BaseProvider` implements it for any
9392
provider that implements `stream()`. Built-in providers:
9493

95-
- `AnthropicProvider` — Claude (Messages API, with thinking, caching,
94+
- `AnthropicProvider` — Claude (Messages API, with reasoning, caching,
9695
tool use).
9796
- `OpenAIProvider` — GPT family (Chat Completions API).
9897
- `OpenAIResponsesProvider` — GPT family (Responses API, server-side

0 commit comments

Comments
 (0)