fix(providers): align GPT-5.6 with the GA API surface

- every 5.6 tier accepts effort "max" and reasoning.mode
  "standard"/"pro" (GA docs: pro is a request mode on any GPT-5.6
  model) -- drop the Sol-only gating
- GPT-5.6 deprecates prompt_cache_retention; send
  prompt_cache_options={"ttl": "30m"} (its only supported lifetime)
  and keep the 24h retention policy for pre-5.6 models
- never inject commercial cache params into local lanes: dropped from
  the Chat Completions lane (which serves only openai-compatible and
  google) and gated off the compat-pinned Responses lane -- a gpt-5*
  served-model name is not an OpenAI account
- account cache writes: usage *_tokens_details.cache_write_tokens
  flows into cache_creation_tokens (5.6 bills writes at 1.25x the
  uncached input rate)
- drop non-string verbosity/reasoning_mode overrides with a warning
  instead of raising on unhashable capability-JSON values
- keep ModelCapabilities' public positional prefix stable by appending
  the verbosity/pro fields at the tail; pin it with a constructor test
- openai floor 2.44 -> 2.45, the first release with the typed
  prompt_cache_options kwarg
This commit is contained in:
Patrick Buckley
2026-07-10 00:29:14 -07:00
parent dc647f4d63
commit b450b9ad20
14 changed files with 262 additions and 101 deletions
+1 -1
View File
@@ -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).
+9 -8
View File
@@ -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
+6 -3
View File
@@ -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"}`
+1 -1
View File
@@ -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
@@ -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"
},
@@ -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"
},
@@ -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"
+175 -25
View File
@@ -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",
+2 -2
View File
@@ -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.
-3
View File
@@ -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:
+30 -30
View File
@@ -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,
)
+11 -4
View File
@@ -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 -----------------------------------------------------------
+14 -17
View File
@@ -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.
Generated
+4 -4
View File
@@ -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" },