diff --git a/docs/api-reference.md b/docs/api-reference.md index 533c6b9f..e9e63af4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -458,7 +458,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`): | `context_window` | int | Total context window size in tokens | | `pct` | float | Percentage of context window used | | `effort` | string | Reasoning effort level (`low`/`medium`/`high`) | -| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) | +| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic + OpenAI) | | `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) | **`info`** -- an informational message (e.g. command output). diff --git a/docs/architecture.md b/docs/architecture.md index aeb9b92c..7035d9c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -622,19 +622,19 @@ LLMProvider (protocol) |------|--------| | `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` | | `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` | -| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` | +| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` | | `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` | **OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are already in OpenAI format), including multi-part content blocks (text + images) -in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4, +in tool results. Model capability lookup covers GPT-5 through GPT-5.6, O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`. For search models, injects `web_search_options` and removes the `web_search` function tool (the model always searches). Citations from `url_citation` -annotations are formatted as footnotes. Extended prompt cache retention -(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no -additional cost. Cached token counts are extracted from -`usage.prompt_tokens_details.cached_tokens`. Unknown models get permissive +annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended +prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses +`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from +`cached_tokens` and `cache_write_tokens`. Unknown models get permissive defaults with `supports_vision=False` and use SearxNG for web search. The `openai-compatible` lane never consults this table at all — on either API surface (the responses pin is served by a compat-mode @@ -642,8 +642,9 @@ surface (the responses pin is served by a compat-mode local server serves whatever the operator named it (vLLM `--served-model-name` is a free string), so a prefix collision with a cloud model id must not inherit that model's sampling/effort contract — every -local model gets the plain defaults, and anything beyond them is declared on -the model definition (capabilities JSON + `server_compat`), matching the +local model gets the plain defaults, commercial prompt-cache controls are not +injected by model-name prefix, and anything beyond those defaults is declared +on the model definition (capabilities JSON + `server_compat`), matching the `anthropic-compatible` lane. **AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to diff --git a/docs/governance.md b/docs/governance.md index 4895eb5a..451f17d7 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -131,9 +131,12 @@ Per-LLM-request token and tool call metrics: LLM response with prompt/completion tokens, cache tokens, tool call count, model, ws_id - **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`) - and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x) - are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are - tracked per request in `usage_events` and surfaced in the Usage admin tab + and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request + `prompt_cache_retention: 24h`; GPT-5.6 uses + `prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the + provider's 1.25× input-token rate. `cache_creation_tokens` and + `cache_read_tokens` are tracked per request in `usage_events` and surfaced + in the Usage admin tab - **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user) and time range filtering — includes cache token aggregates - **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}` diff --git a/pyproject.toml b/pyproject.toml index 5a6363a9..38aff7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ - "openai>=2.44", # GPT-5.6 (Sol/Terra/Luna): Responses reasoning.mode + effort "max" + text.verbosity + "openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens "anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks) "httpx>=0.28", "mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately diff --git a/tests/data/wire_payloads/openai_responses_max__text.json b/tests/data/wire_payloads/openai_responses_max__text.json index 2f6b17e3..782e33b1 100644 --- a/tests/data/wire_payloads/openai_responses_max__text.json +++ b/tests/data/wire_payloads/openai_responses_max__text.json @@ -21,7 +21,9 @@ ], "max_output_tokens": 4096, "model": "gpt-5.6-sol", - "prompt_cache_retention": "24h", + "prompt_cache_options": { + "ttl": "30m" + }, "reasoning": { "effort": "max" }, diff --git a/tests/data/wire_payloads/openai_responses_max__toolcall_complete.json b/tests/data/wire_payloads/openai_responses_max__toolcall_complete.json index 1c04a0e0..19aebb6b 100644 --- a/tests/data/wire_payloads/openai_responses_max__toolcall_complete.json +++ b/tests/data/wire_payloads/openai_responses_max__toolcall_complete.json @@ -27,7 +27,9 @@ ], "max_output_tokens": 4096, "model": "gpt-5.6-sol", - "prompt_cache_retention": "24h", + "prompt_cache_options": { + "ttl": "30m" + }, "reasoning": { "effort": "max" }, diff --git a/tests/data/wire_payloads/openai_responses_verbosity_pro__text.json b/tests/data/wire_payloads/openai_responses_verbosity_pro__text.json index 0d1aa4bb..a4f095ce 100644 --- a/tests/data/wire_payloads/openai_responses_verbosity_pro__text.json +++ b/tests/data/wire_payloads/openai_responses_verbosity_pro__text.json @@ -21,7 +21,9 @@ ], "max_output_tokens": 4096, "model": "gpt-5.6-sol", - "prompt_cache_retention": "24h", + "prompt_cache_options": { + "ttl": "30m" + }, "reasoning": { "effort": "high", "mode": "pro" diff --git a/tests/test_providers.py b/tests/test_providers.py index 12302563..19e57e17 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -16,6 +16,7 @@ from turnstone.core.providers._openai_common import ( apply_cache_retention, apply_temperature_and_effort, apply_tool_search, + extract_usage, format_citations, lookup_openai_capabilities, sanitize_messages, @@ -2236,15 +2237,14 @@ class TestOpenAIParameterGating: assert "max" in lookup_openai_capabilities("gpt-5.6-sol").reasoning_effort_values assert "max" in lookup_openai_capabilities("gpt-5.6-2026-07-09").reasoning_effort_values - def test_gpt56_terra_luna_max_snaps_to_xhigh_ceiling(self) -> None: - """GPT-5.6 Terra and Luna have no "max" (Sol-only); the knob's "max" - snaps DOWN to the declared "xhigh" ceiling rather than being dropped.""" + def test_gpt56_terra_luna_support_max_effort(self) -> None: + """Every GPT-5.6 tier accepts the documented "max" effort.""" for tier in ("gpt-5.6-terra", "gpt-5.6-luna"): caps = lookup_openai_capabilities(tier) - assert "max" not in caps.reasoning_effort_values, tier + assert "max" in caps.reasoning_effort_values, tier kwargs: dict[str, Any] = {} apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="max") - assert kwargs["reasoning_effort"] == "xhigh", tier + assert kwargs["reasoning_effort"] == "max", tier class TestAnthropicOrphanedToolUse: @@ -3495,6 +3495,31 @@ class TestModelCapabilitiesToolSearch: caps = ModelCapabilities() assert caps.supports_tool_search is False + def test_public_positional_prefix_remains_stable(self) -> None: + """New optional fields must not shift the exported constructor's existing slots.""" + caps = ModelCapabilities( + 100000, + 10000, + False, + False, + False, + "max_tokens", + "manual", + "thinking", + "reasoning_effort", + True, + ("low",), + ("low",), + "low", + True, + True, + True, + True, + ) + assert caps.supports_web_search is True + assert caps.supports_tool_search is True + assert caps.supports_vision is True + class TestMidConversationSystemCapability: """supports_mid_conversation_system — NextOpus (claude-opus-4-8) only.""" @@ -3943,8 +3968,50 @@ class TestOpenAIPromptCaching: def setup_method(self) -> None: self.provider = OpenAIProvider() - def test_cache_retention_set_for_gpt5(self) -> None: - """GPT-5.x models get prompt_cache_retention=24h.""" + @pytest.mark.parametrize("model", ("gpt-5.5-local-lora", "gpt-5.6-local-lora")) + def test_chat_compat_streaming_omits_commercial_cache_params(self, model: str) -> None: + """A local model name must not activate commercial OpenAI cache controls.""" + client = MagicMock() + client.chat.completions.create.return_value = iter(()) + + list( + self.provider.create_streaming( + client=client, + model=model, + messages=[{"role": "user", "content": "hi"}], + ) + ) + + sent = client.chat.completions.create.call_args.kwargs + assert "prompt_cache_retention" not in sent + assert "prompt_cache_options" not in sent + + @pytest.mark.parametrize("model", ("gpt-5.5-local-lora", "gpt-5.6-local-lora")) + def test_chat_compat_completion_omits_commercial_cache_params(self, model: str) -> None: + """The non-streaming local lane has the same cache-parameter isolation.""" + response = MagicMock() + response.choices = [ + MagicMock( + message=MagicMock(content="hello", tool_calls=None, annotations=None), + finish_reason="stop", + ) + ] + response.usage = None + client = MagicMock() + client.chat.completions.create.return_value = response + + self.provider.create_completion( + client=client, + model=model, + messages=[{"role": "user", "content": "hi"}], + ) + + sent = client.chat.completions.create.call_args.kwargs + assert "prompt_cache_retention" not in sent + assert "prompt_cache_options" not in sent + + def test_cache_retention_set_for_pre_gpt56_models(self) -> None: + """Pre-5.6 GPT-5 models retain the legacy 24-hour cache policy.""" for model in ( "gpt-5", "gpt-5.1", @@ -3953,16 +4020,21 @@ class TestOpenAIPromptCaching: "gpt-5.4-pro", "gpt-5.5", "gpt-5.5-pro", - "gpt-5.6", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", "gpt-5-mini", "gpt-5-pro", ): kwargs: dict[str, Any] = {} apply_cache_retention(kwargs, model) assert kwargs.get("prompt_cache_retention") == "24h", f"Failed for {model}" + assert "prompt_cache_options" not in kwargs + + def test_gpt56_uses_prompt_cache_options(self) -> None: + """GPT-5.6 uses the replacement cache API introduced in SDK 2.45.""" + for model in ("gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + kwargs: dict[str, Any] = {} + apply_cache_retention(kwargs, model) + assert kwargs.get("prompt_cache_options") == {"ttl": "30m"}, model + assert "prompt_cache_retention" not in kwargs def test_cache_retention_not_set_for_non_gpt5(self) -> None: """Non-GPT-5 models do not get cache retention.""" @@ -3970,6 +4042,38 @@ class TestOpenAIPromptCaching: kwargs: dict[str, Any] = {} apply_cache_retention(kwargs, model) assert "prompt_cache_retention" not in kwargs, f"Unexpected retention for {model}" + assert "prompt_cache_options" not in kwargs, f"Unexpected options for {model}" + + def test_cache_write_tokens_from_responses_usage(self) -> None: + """GPT-5.6 cache writes flow into normalized usage accounting.""" + usage = MagicMock() + usage.prompt_tokens = None + usage.input_tokens = 100 + usage.completion_tokens = None + usage.output_tokens = 20 + usage.total_tokens = 120 + usage.prompt_tokens_details = None + usage.input_tokens_details = MagicMock(cached_tokens=30, cache_write_tokens=70) + + normalized = extract_usage(usage) + + assert normalized is not None + assert normalized.cache_read_tokens == 30 + assert normalized.cache_creation_tokens == 70 + + def test_cache_write_tokens_from_chat_usage(self) -> None: + """The Chat Completions usage shape reports the same cache-write metric.""" + usage = MagicMock() + usage.prompt_tokens = 100 + usage.completion_tokens = 20 + usage.total_tokens = 120 + usage.prompt_tokens_details = MagicMock(cached_tokens=30, cache_write_tokens=70) + + normalized = extract_usage(usage) + + assert normalized is not None + assert normalized.cache_read_tokens == 30 + assert normalized.cache_creation_tokens == 70 def test_streaming_cached_tokens_from_usage(self) -> None: """Streaming usage extracts cached_tokens from prompt_tokens_details.""" @@ -4393,8 +4497,7 @@ class TestResponsesParamBuilding: assert kwargs["reasoning"] == {"effort": "high", "mode": "pro"} def test_pro_mode_rejected_when_unsupported(self) -> None: - """A pro reasoning_mode on Terra/Luna (supports_pro_mode False) is - dropped — effort still rides, mode does not.""" + """A pro mode on a model without reasoning-mode support is dropped.""" caps = ModelCapabilities( supports_pro_mode=False, reasoning_mode="pro", @@ -4410,6 +4513,16 @@ class TestResponsesParamBuilding: kwargs = self._build(caps, reasoning_effort="medium") assert kwargs["reasoning"] == {"mode": "pro"} + def test_standard_reasoning_mode_is_accepted(self) -> None: + """The SDK's explicit standard mode is valid even though omission is equivalent.""" + caps = ModelCapabilities( + supports_pro_mode=True, + reasoning_mode="standard", + reasoning_effort_values=("low", "medium", "high"), + ) + kwargs = self._build(caps, reasoning_effort="high") + assert kwargs["reasoning"] == {"effort": "high", "mode": "standard"} + def test_verbosity_unknown_value_dropped(self) -> None: """A verbosity outside {low,medium,high} is dropped, not sent — an operator typo must not 400 every request.""" @@ -4426,9 +4539,23 @@ class TestResponsesParamBuilding: kwargs = self._build(caps, reasoning_effort="high") assert kwargs["reasoning"] == {"effort": "high"} - def test_gpt56_terra_max_snaps_to_xhigh_on_responses_wire(self) -> None: - """Terra's knob "max" snaps to the xhigh ceiling on the ACTUAL - Responses wire path (_build_kwargs), not only the shared resolver.""" + def test_verbosity_non_string_value_dropped(self) -> None: + """Malformed operator JSON must not crash request construction.""" + kwargs = self._build(ModelCapabilities(supports_verbosity=True, verbosity=["low"])) + assert "text" not in kwargs + + def test_pro_mode_non_string_value_dropped(self) -> None: + """Malformed operator JSON must not crash request construction.""" + caps = ModelCapabilities( + supports_pro_mode=True, + reasoning_mode=["pro"], + reasoning_effort_values=("low", "medium", "high"), + ) + kwargs = self._build(caps, reasoning_effort="high") + assert kwargs["reasoning"] == {"effort": "high"} + + def test_gpt56_terra_max_reaches_responses_wire(self) -> None: + """Terra sends the documented max effort on the actual Responses path.""" kwargs = self.provider._build_kwargs( model="gpt-5.6-terra", messages=[{"role": "user", "content": "Hi"}], @@ -4438,19 +4565,14 @@ class TestResponsesParamBuilding: reasoning_effort="max", deferred_names=None, ) - assert kwargs["reasoning"] == {"effort": "xhigh"} + assert kwargs["reasoning"] == {"effort": "max"} def test_gpt56_verbosity_and_pro_flags(self) -> None: - """The static rows carry the right capability flags: verbosity on all - three tiers, pro mode on Sol/alias only.""" - sol = lookup_openai_capabilities("gpt-5.6-sol") - assert sol.supports_verbosity is True - assert sol.supports_pro_mode is True - assert lookup_openai_capabilities("gpt-5.6").supports_pro_mode is True - for tier in ("gpt-5.6-terra", "gpt-5.6-luna"): + """Every GPT-5.6 tier supports verbosity and pro reasoning mode.""" + for tier in ("gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): caps = lookup_openai_capabilities(tier) assert caps.supports_verbosity is True - assert caps.supports_pro_mode is False + assert caps.supports_pro_mode is True def _kwargs_with(self, tools: list[dict[str, Any]], caps: ModelCapabilities) -> dict[str, Any]: return self.provider._build_kwargs( @@ -4503,6 +4625,34 @@ class TestResponsesParamBuilding: ) assert kwargs["prompt_cache_retention"] == "24h" + def test_compat_responses_omits_commercial_cache_params(self) -> None: + provider = type(self.provider)(compat=True) + for model in ("gpt-5.5-local-lora", "gpt-5.6-local-lora"): + kwargs = provider._build_kwargs( + model=model, + messages=[{"role": "user", "content": "Hi"}], + tools=None, + max_tokens=4096, + temperature=0.5, + reasoning_effort="medium", + deferred_names=None, + ) + assert "prompt_cache_retention" not in kwargs, model + assert "prompt_cache_options" not in kwargs, model + + def test_cache_options_for_gpt56(self) -> None: + kwargs = self.provider._build_kwargs( + model="gpt-5.6-sol", + messages=[{"role": "user", "content": "Hi"}], + tools=None, + max_tokens=4096, + temperature=0.5, + reasoning_effort="medium", + deferred_names=None, + ) + assert kwargs["prompt_cache_options"] == {"ttl": "30m"} + assert "prompt_cache_retention" not in kwargs + def test_instructions_from_system_messages(self) -> None: kwargs = self.provider._build_kwargs( model="gpt-5.4", diff --git a/tests/test_wire_payload_golden.py b/tests/test_wire_payload_golden.py index 037eda0c..fae366b5 100644 --- a/tests/test_wire_payload_golden.py +++ b/tests/test_wire_payload_golden.py @@ -311,8 +311,8 @@ def test_wire_payload_anthropic_compat(fixture_id: str) -> None: _assert_golden(f"anthropic_compat__{fixture_id}", payload) -# GPT-5.6 Sol is the first COMMERCIAL OpenAI model to expose the "max" -# reasoning effort (Terra/Luna cap at "xhigh"; see OPENAI_CAPABILITIES). +# GPT-5.6 is the first commercial OpenAI family to expose the "max" +# reasoning effort across Sol, Terra, and Luna (see OPENAI_CAPABILITIES). # The base matrix above pins only the default-effort Responses shape # (gpt-5 → "medium"), so freeze a max-effort request to prove the new # level compiles onto the native ``reasoning={"effort": "max"}`` param. diff --git a/turnstone/core/providers/_openai_chat.py b/turnstone/core/providers/_openai_chat.py index 18920bd0..2f4beae5 100644 --- a/turnstone/core/providers/_openai_chat.py +++ b/turnstone/core/providers/_openai_chat.py @@ -16,7 +16,6 @@ import structlog from turnstone.core.providers._openai_common import ( OPENAI_COMPAT_DEFAULT, RETRYABLE_ERROR_NAMES, - apply_cache_retention, apply_temperature_and_effort, apply_tool_search, extract_usage, @@ -178,7 +177,6 @@ class OpenAIChatCompletionsProvider: "stream_options": {"include_usage": True}, } apply_temperature_and_effort(kwargs, caps, temperature, reasoning_effort) - apply_cache_retention(kwargs, model) tools = self._apply_web_search(kwargs, caps, tools) tools = apply_tool_search(caps, tools, deferred_names) if tools: @@ -313,7 +311,6 @@ class OpenAIChatCompletionsProvider: "stream": False, } apply_temperature_and_effort(kwargs, caps, temperature, reasoning_effort) - apply_cache_retention(kwargs, model) tools = self._apply_web_search(kwargs, caps, tools) tools = apply_tool_search(caps, tools, deferred_names) if tools: diff --git a/turnstone/core/providers/_openai_common.py b/turnstone/core/providers/_openai_common.py index ef69c2e3..567a599b 100644 --- a/turnstone/core/providers/_openai_common.py +++ b/turnstone/core/providers/_openai_common.py @@ -173,22 +173,11 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_reasoning_replay=True, ), # GPT-5.6 (Sol / Terra / Luna) — released 2026-07-09. The bare - # "gpt-5.6" alias routes to Sol (developers.openai.com/api/docs/guides/ - # latest-model, 2026-07 check), so this catch-all row carries Sol's - # caps and also covers dated Sol snapshots ("gpt-5.6-2026-..") and the - # explicit "gpt-5.6-sol" id by longest-prefix match. Sol is the ONLY - # 5.6 tier that unlocks the new "max" reasoning effort — the first - # COMMERCIAL OpenAI model to use it (KNOB_EFFORT_ORDER already ranks - # "max" for the Anthropic lane, so the ordinal snap and effort ladder - # need no change). Sol also has a Sol-only "ultra" multi-agent mode - # that Turnstone does NOT expose (only "pro" is wired — see below). - # Default effort is "medium" like gpt-5.5; temperature is accepted only at - # reasoning_effort="none" (the "none"-in-values gate). There is NO - # gpt-5.6-pro model: "pro" is now a reasoning.mode="pro" request param, - # not a separate model id. Context window is not yet on the model page - # (limited preview); 1.05M mirrors the 5.4/5.5 lineage — override via - # the DB model definition if OpenAI publishes a different window (a - # smaller Luna window has been reported but is unconfirmed). + # "gpt-5.6" alias routes to Sol, so this catch-all row also covers the + # explicit "gpt-5.6-sol" id by longest-prefix match. Every tier supports + # the family reasoning ladder through "max", output verbosity, and + # reasoning.mode="pro"; there is no separate gpt-5.6-pro model. Default + # effort is "medium" and temperature is accepted only when effort="none". "gpt-5.6": ModelCapabilities( context_window=1050000, max_output_tokens=128000, @@ -199,32 +188,33 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_pdf=True, supports_reasoning_replay=True, supports_verbosity=True, - supports_pro_mode=True, # Sol-only reasoning.mode="pro" + supports_pro_mode=True, ), - # GPT-5.6 Terra — balanced tier; Sol's ladder minus "max" (Sol-only), - # so the knob's "max" snaps to the "xhigh" ceiling. No pro mode. + # GPT-5.6 Terra — balanced intelligence/cost tier. "gpt-5.6-terra": ModelCapabilities( context_window=1050000, max_output_tokens=128000, - reasoning_effort_values=("none", "low", "medium", "high", "xhigh"), + reasoning_effort_values=("none", "low", "medium", "high", "xhigh", "max"), default_reasoning_effort="medium", supports_tool_search=True, supports_vision=True, supports_pdf=True, supports_reasoning_replay=True, supports_verbosity=True, + supports_pro_mode=True, ), - # GPT-5.6 Luna — fastest/cheapest tier; no "max" effort, no pro mode. + # GPT-5.6 Luna — cost-sensitive, high-volume tier. "gpt-5.6-luna": ModelCapabilities( context_window=1050000, max_output_tokens=128000, - reasoning_effort_values=("none", "low", "medium", "high", "xhigh"), + reasoning_effort_values=("none", "low", "medium", "high", "xhigh", "max"), default_reasoning_effort="medium", supports_tool_search=True, supports_vision=True, supports_pdf=True, supports_reasoning_replay=True, supports_verbosity=True, + supports_pro_mode=True, ), # O-series reasoning models "o1": ModelCapabilities( @@ -415,14 +405,15 @@ def apply_temperature_and_effort( def apply_cache_retention(kwargs: dict[str, Any], model: str) -> None: - """Enable 24-hour extended prompt cache retention for GPT-5.x models. + """Configure the prompt-cache lifetime supported by each GPT-5 generation. - OpenAI caching is automatic (no code changes for basic caching), but - the default TTL is only 5-10 minutes. Extended retention keeps cached - KV tensors for up to 24 hours at no additional cost, which is valuable - for workstreams with bursty activity patterns. + GPT-5.6 replaces the deprecated ``prompt_cache_retention`` field with + ``prompt_cache_options.ttl``; 30 minutes is currently its only accepted + minimum lifetime. Earlier GPT-5 models retain the 24-hour policy. """ - if model.startswith("gpt-5"): + if model.startswith("gpt-5.6"): + kwargs["prompt_cache_options"] = {"ttl": "30m"} + elif model.startswith("gpt-5"): kwargs["prompt_cache_retention"] = "24h" @@ -439,7 +430,7 @@ def apply_cache_retention(kwargs: dict[str, Any], model: str) -> None: # The emission sites drop unknown values with a warning instead, mirroring # how ``model_registry`` clamps out-of-range temperature / max_tokens. VERBOSITY_LEVELS: frozenset[str] = frozenset({"low", "medium", "high"}) -REASONING_MODES: frozenset[str] = frozenset({"pro"}) +REASONING_MODES: frozenset[str] = frozenset({"standard", "pro"}) def apply_verbosity(kwargs: dict[str, Any], caps: ModelCapabilities) -> None: @@ -456,7 +447,14 @@ def apply_verbosity(kwargs: dict[str, Any], caps: ModelCapabilities) -> None: ``apply_temperature``); a value outside ``VERBOSITY_LEVELS`` is dropped with a warning (an operator typo must not 400 every request). """ - if not (caps.supports_verbosity and caps.verbosity): + if not caps.supports_verbosity or caps.verbosity == "": + return + if not isinstance(caps.verbosity, str): + log.warning( + "openai.responses: ignoring non-string verbosity", + value=caps.verbosity, + expected=sorted(VERBOSITY_LEVELS), + ) return if caps.verbosity not in VERBOSITY_LEVELS: log.warning( @@ -818,11 +816,13 @@ def extract_usage(usage_obj: Any) -> UsageInfo | None: if ptd is None: ptd = getattr(usage_obj, "input_tokens_details", None) cached = getattr(ptd, "cached_tokens", 0) if ptd is not None else 0 + cache_written = getattr(ptd, "cache_write_tokens", 0) if ptd is not None else 0 return UsageInfo( prompt_tokens=pt, completion_tokens=ct, total_tokens=tt if isinstance(tt, int) else (pt + ct), + cache_creation_tokens=cache_written if isinstance(cache_written, int) else 0, cache_read_tokens=cached if isinstance(cached, int) else 0, ) diff --git a/turnstone/core/providers/_openai_responses.py b/turnstone/core/providers/_openai_responses.py index d758b2ec..5a2421fe 100644 --- a/turnstone/core/providers/_openai_responses.py +++ b/turnstone/core/providers/_openai_responses.py @@ -438,7 +438,7 @@ class OpenAIResponsesProvider: apply_temperature(kwargs, caps, temperature, reasoning_effort) # Reasoning params → {"effort": ..., "mode": ...} (Responses format). - # "mode": "pro" (GPT-5.6 Sol) applies more model work before a single + # "mode": "pro" (GPT-5.6) applies more model work before a single # final answer; it rides with or without an effort level (effort # defaults to medium in pro mode), and effort still rides without a # mode. Both are operator-declared and gated by their static @@ -447,8 +447,14 @@ class OpenAIResponsesProvider: effort = resolve_reasoning_effort(caps, reasoning_effort) if effort: reasoning["effort"] = effort - if caps.supports_pro_mode and caps.reasoning_mode: - if caps.reasoning_mode in REASONING_MODES: + if caps.supports_pro_mode and caps.reasoning_mode != "": + if not isinstance(caps.reasoning_mode, str): + log.warning( + "openai.responses: ignoring non-string reasoning mode", + value=caps.reasoning_mode, + expected=sorted(REASONING_MODES), + ) + elif caps.reasoning_mode in REASONING_MODES: reasoning["mode"] = caps.reasoning_mode else: log.warning( @@ -460,7 +466,8 @@ class OpenAIResponsesProvider: kwargs["reasoning"] = reasoning apply_verbosity(kwargs, caps) - apply_cache_retention(kwargs, model) + if not self._compat: + apply_cache_retention(kwargs, model) return kwargs # -- streaming ----------------------------------------------------------- diff --git a/turnstone/core/providers/_protocol.py b/turnstone/core/providers/_protocol.py index 7bffbe45..4088c2a7 100644 --- a/turnstone/core/providers/_protocol.py +++ b/turnstone/core/providers/_protocol.py @@ -102,23 +102,6 @@ class ModelCapabilities: # this False: there, an empty values list means the model has no # effort control at all (o1-mini) and the param must be omitted. effort_passthrough: bool = False - # Responses-API output-length control (GPT-5 family): "low"/"medium"/ - # "high", separate from reasoning effort. ``supports_verbosity`` is the - # static capability; ``verbosity`` is the operator-declared value - # (model-definition capabilities JSON, merged via - # ``ChatSession._resolve_capabilities``), "" = omit. Nests under - # ``text.verbosity`` on the Responses wire (a top-level ``verbosity`` - # 400s there); the Chat/compat lane never emits it. A value set on a - # model whose ``supports_verbosity`` is False is dropped, not sent. - supports_verbosity: bool = False - verbosity: str = "" - # Responses-API ``reasoning.mode`` (GPT-5.6 Sol): "pro" applies more - # model work before a single final answer. ``supports_pro_mode`` is the - # static capability (Sol-only); ``reasoning_mode`` is the - # operator-declared value, "" = omit (normal reasoning). There is no - # gpt-5.6-pro *model* — "pro" is this request-level mode instead. - supports_pro_mode: bool = False - reasoning_mode: str = "" supports_web_search: bool = False supports_tool_search: bool = False supports_vision: bool = False @@ -170,6 +153,20 @@ class ModelCapabilities: rerank_threshold: float = 0.0 rerank_scale: str = "" rerank_separated: bool = False + # Responses-API output-length control (GPT-5 family): "low"/"medium"/ + # "high", separate from reasoning effort. Appended rather than inserted + # above to preserve the public dataclass constructor's positional order. + # ``supports_verbosity`` is the static capability; ``verbosity`` is the + # operator-declared value (model-definition capabilities JSON, merged via + # ``ChatSession._resolve_capabilities``), "" = omit. Nests under + # ``text.verbosity`` on the Responses wire. + supports_verbosity: bool = False + verbosity: str = "" + # Responses-API ``reasoning.mode`` for GPT-5.6. ``supports_pro_mode`` is + # the static capability; ``reasoning_mode`` is the operator-declared value, + # "" = omit (standard reasoning). There is no gpt-5.6-pro model. + supports_pro_mode: bool = False + reasoning_mode: str = "" # The session effort knob is ORDINAL — snapping must respect this order. diff --git a/uv.lock b/uv.lock index 92a88d79..2c5d0cea 100644 --- a/uv.lock +++ b/uv.lock @@ -1473,7 +1473,7 @@ wheels = [ [[package]] name = "openai" -version = "2.44.0" +version = "2.45.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1485,9 +1485,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, ] [[package]] @@ -2544,7 +2544,7 @@ requires-dist = [ { name = "lacme", specifier = ">=1.0.5" }, { name = "mcp", specifier = ">=1.27,<2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, - { name = "openai", specifier = ">=2.44" }, + { name = "openai", specifier = ">=2.45" }, { name = "pillow", specifier = ">=10" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.0" },