Merge pull request #140 from bnema/feat/openai-agent-cache-correctness
feat(provider): improve OpenAI cache correctness
46 files · +1246 -266
M docs/providers.md +27 -11
| @@ -150,22 +150,38 @@ profile sets `fastMode` explicitly. | |||
| 150 | 150 | ||
| 151 | 151 | ## Session identity and caching | |
| 152 | 152 | ||
| 153 | - | zut supplies every provider request with a stable logical session ID and a | |
| 154 | - | per-prompt turn ID. They are opaque local correlations; adapters translate them | |
| 155 | - | only when their exact provider, model, and endpoint declaration supports it. | |
| 156 | - | For example, the OpenAI/Codex Responses route uses the stable session as its | |
| 157 | - | `prompt_cache_key` and, where required by the declared Codex route, its | |
| 158 | - | `session-id` header. A generic OpenAI-compatible endpoint never receives those | |
| 159 | - | extensions merely because it accepts an OpenAI-shaped request. | |
| 153 | + | zut supplies every provider request with a root cache identity, a | |
| 154 | + | conversation-thread identity, and a per-prompt turn ID. They are opaque local | |
| 155 | + | correlations; adapters translate them only when their exact provider, model, | |
| 156 | + | and endpoint declaration supports it. Resident children share the root cache | |
| 157 | + | identity but retain distinct thread identities. The OpenAI/Codex Responses | |
| 158 | + | route uses the root identity as `prompt_cache_key`; declared Codex routes also | |
| 159 | + | receive their documented session and thread routing headers. A generic | |
| 160 | + | OpenAI-compatible endpoint never receives these extensions merely because it | |
| 161 | + | accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache | |
| 162 | + | diagnostics (`eligible`, `mode`, `transport`, and `continuation`). `eligible` | |
| 163 | + | is a conservative local prompt-length estimate; OpenAI performs the authoritative | |
| 164 | + | tokenization and cache decision. These records never include prompts, durable | |
| 165 | + | IDs, request bodies, or credentials. | |
| 166 | + | ||
| 167 | + | When a provider reports cache detail, zut displays cache-read hit rate as | |
| 168 | + | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | |
| 169 | + | cache miss; no percentage means the provider or historical session did not | |
| 170 | + | report cache detail. Cache writes remain separate from that hit rate. Provider | |
| 171 | + | retention and eviction, request routing, and OpenAI's 1,024-token eligibility | |
| 172 | + | minimum can still produce a miss even when the stable prefix is unchanged. | |
| 160 | 173 | ||
| 161 | 174 | ### OpenAI Responses WebSocket mode | |
| 162 | 175 | ||
| 163 | 176 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | |
| 164 | 177 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | |
| 165 | - | connection per logical session, sends one `response.create` at a time, and | |
| 166 | - | continues the most recent completed response with `previous_response_id` plus | |
| 167 | - | only new tool or user input. The request uses `store:false`; its only | |
| 168 | - | provider-specific handshake header is `Authorization: Bearer ...`, never the | |
| 178 | + | connection per conversation thread, sends one `response.create` at a time, | |
| 179 | + | and continues the most recent completed response with `previous_response_id` | |
| 180 | + | only when the complete response-context configuration and prior input prefix | |
| 181 | + | match exactly, including the completed assistant output that precedes a new | |
| 182 | + | suffix. Compatible continuations send only new tool or user input. | |
| 183 | + | The request uses `store:false`; its only provider-specific handshake header is | |
| 184 | + | `Authorization: Bearer ...`, never the | |
| 169 | 185 | ChatGPT/Codex account, routing, or session headers. | |
| 170 | 186 | ||
| 171 | 187 | The capability is deliberately not inherited by `--base-url`, compatible | |
| @@ -150,22 +150,38 @@ profile sets `fastMode` explicitly. | |||||
| 150 | 150 | ||||
| 151 | ## Session identity and caching | 151 | ## Session identity and caching | ||
| 152 | 152 | ||||
| 153 | - | zut supplies every provider request with a stable logical session ID and a | 153 | + | zut supplies every provider request with a root cache identity, a |
| 154 | - | per-prompt turn ID. They are opaque local correlations; adapters translate them | 154 | + | conversation-thread identity, and a per-prompt turn ID. They are opaque local |
| 155 | - | only when their exact provider, model, and endpoint declaration supports it. | 155 | + | correlations; adapters translate them only when their exact provider, model, |
| 156 | - | For example, the OpenAI/Codex Responses route uses the stable session as its | 156 | + | and endpoint declaration supports it. Resident children share the root cache |
| 157 | - | `prompt_cache_key` and, where required by the declared Codex route, its | 157 | + | identity but retain distinct thread identities. The OpenAI/Codex Responses |
| 158 | - | `session-id` header. A generic OpenAI-compatible endpoint never receives those | 158 | + | route uses the root identity as `prompt_cache_key`; declared Codex routes also |
| 159 | - | extensions merely because it accepts an OpenAI-shaped request. | 159 | + | receive their documented session and thread routing headers. A generic |
| 160 | + | OpenAI-compatible endpoint never receives these extensions merely because it | |||
| 161 | + | accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache | |||
| 162 | + | diagnostics (`eligible`, `mode`, `transport`, and `continuation`). `eligible` | |||
| 163 | + | is a conservative local prompt-length estimate; OpenAI performs the authoritative | |||
| 164 | + | tokenization and cache decision. These records never include prompts, durable | |||
| 165 | + | IDs, request bodies, or credentials. | |||
| 166 | + | ||||
| 167 | + | When a provider reports cache detail, zut displays cache-read hit rate as | |||
| 168 | + | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | |||
| 169 | + | cache miss; no percentage means the provider or historical session did not | |||
| 170 | + | report cache detail. Cache writes remain separate from that hit rate. Provider | |||
| 171 | + | retention and eviction, request routing, and OpenAI's 1,024-token eligibility | |||
| 172 | + | minimum can still produce a miss even when the stable prefix is unchanged. | |||
| 160 | 173 | ||||
| 161 | ### OpenAI Responses WebSocket mode | 174 | ### OpenAI Responses WebSocket mode | ||
| 162 | 175 | ||||
| 163 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | 176 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | ||
| 164 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | 177 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | ||
| 165 | - | connection per logical session, sends one `response.create` at a time, and | 178 | + | connection per conversation thread, sends one `response.create` at a time, |
| 166 | - | continues the most recent completed response with `previous_response_id` plus | 179 | + | and continues the most recent completed response with `previous_response_id` |
| 167 | - | only new tool or user input. The request uses `store:false`; its only | 180 | + | only when the complete response-context configuration and prior input prefix |
| 168 | - | provider-specific handshake header is `Authorization: Bearer ...`, never the | 181 | + | match exactly, including the completed assistant output that precedes a new |
| 182 | + | suffix. Compatible continuations send only new tool or user input. | |||
| 183 | + | The request uses `store:false`; its only provider-specific handshake header is | |||
| 184 | + | `Authorization: Bearer ...`, never the | |||
| 169 | ChatGPT/Codex account, routing, or session headers. | 185 | ChatGPT/Codex account, routing, or session headers. | ||
| 170 | 186 | ||||
| 171 | The capability is deliberately not inherited by `--base-url`, compatible | 187 | The capability is deliberately not inherited by `--base-url`, compatible | ||
M docs/rpc.md +3 -2
| @@ -123,7 +123,7 @@ Response data: | |||
| 123 | 123 | "cwd": "/path/to/zut", | |
| 124 | 124 | "message_count": 12, | |
| 125 | 125 | "busy": false, | |
| 126 | - | "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123} | |
| 126 | + | "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cache_measured_prompt": 1234, "cache_measured_read": 890, "cost_usd": 0.0123} | |
| 127 | 127 | } | |
| 128 | 128 | ``` | |
| 129 | 129 | ||
| @@ -193,6 +193,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 193 | 193 | |---|---|---| | |
| 194 | 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | |
| 195 | 195 | | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started | | |
| 196 | + | | `cache_diagnostics` | `eligible`, `mode`, `transport`, `continuation` | Sanitized cache-routing state. `eligible` is a conservative local prompt-length estimate; it never includes prompts, IDs, request bodies, or credentials | | |
| 196 | 197 | | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer | | |
| 197 | 198 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | |
| 198 | 199 | | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming | | |
| @@ -205,7 +206,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 205 | 206 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | |
| 206 | 207 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |
| 207 | 208 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |
| 208 | - | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. `reasoning` is null when unavailable. | | |
| 209 | + | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | |
| 209 | 210 | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | |
| 210 | 211 | | `done` | (none) | The whole prompt/compact completed (success or error) | | |
| 211 | 212 | | `error` | `message` | Non-fatal error message | | |
| @@ -123,7 +123,7 @@ Response data: | |||||
| 123 | "cwd": "/path/to/zut", | 123 | "cwd": "/path/to/zut", | ||
| 124 | "message_count": 12, | 124 | "message_count": 12, | ||
| 125 | "busy": false, | 125 | "busy": false, | ||
| 126 | - | "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123} | 126 | + | "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cache_measured_prompt": 1234, "cache_measured_read": 890, "cost_usd": 0.0123} |
| 127 | } | 127 | } | ||
| 128 | ``` | 128 | ``` | ||
| 129 | 129 | ||||
| @@ -193,6 +193,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 193 | |---|---|---| | 193 | |---|---|---| | ||
| 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | ||
| 195 | | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started | | 195 | | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started | | ||
| 196 | + | | `cache_diagnostics` | `eligible`, `mode`, `transport`, `continuation` | Sanitized cache-routing state. `eligible` is a conservative local prompt-length estimate; it never includes prompts, IDs, request bodies, or credentials | | |||
| 196 | | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer | | 197 | | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer | | ||
| 197 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | 198 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | ||
| 198 | | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming | | 199 | | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming | | ||
| @@ -205,7 +206,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 205 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | 206 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | ||
| 206 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | 207 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | ||
| 207 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | 208 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | ||
| 208 | - | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. `reasoning` is null when unavailable. | | 209 | + | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | |
| 209 | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | 210 | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | ||
| 210 | | `done` | (none) | The whole prompt/compact completed (success or error) | | 211 | | `done` | (none) | The whole prompt/compact completed (success or error) | | ||
| 211 | | `error` | `message` | Non-fatal error message | | 212 | | `error` | `message` | Non-fatal error message | | ||
M packages/agent/modes/json.go +15 -6
| @@ -82,6 +82,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 82 | 82 | case core.EvAssistantMessage: | |
| 83 | 83 | m["content"] = ContentToJSON(e.Message.Content) | |
| 84 | 84 | m["time"] = e.Message.Time | |
| 85 | + | case core.EvCacheDiagnostics: | |
| 86 | + | m["eligible"] = e.Eligible | |
| 87 | + | m["mode"] = e.Mode | |
| 88 | + | m["transport"] = e.Transport | |
| 89 | + | m["continuation"] = e.Continuation | |
| 85 | 90 | case core.EvTextDelta: | |
| 86 | 91 | m["delta"] = e.Delta | |
| 87 | 92 | case core.EvToolUseStart: | |
| @@ -114,14 +119,18 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 114 | 119 | m["reasoning"] = nullableReasoningTokens(e.Usage) | |
| 115 | 120 | m["cache_read"] = e.Usage.CacheReadTokens | |
| 116 | 121 | m["cache_write"] = e.Usage.CacheWriteTokens | |
| 122 | + | m["cache_measured_prompt"] = e.Usage.CacheMeasuredPromptTokens | |
| 123 | + | m["cache_measured_read"] = e.Usage.CacheMeasuredReadTokens | |
| 117 | 124 | m["cost_usd"] = e.Usage.CostUSD | |
| 118 | 125 | m["cumulative"] = map[string]any{ | |
| 119 | - | "input": e.Cumulative.InputTokens, | |
| 120 | - | "output": e.Cumulative.OutputTokens, | |
| 121 | - | "reasoning": nullableReasoningTokens(e.Cumulative), | |
| 122 | - | "cache_read": e.Cumulative.CacheReadTokens, | |
| 123 | - | "cache_write": e.Cumulative.CacheWriteTokens, | |
| 124 | - | "cost_usd": e.Cumulative.CostUSD, | |
| 126 | + | "input": e.Cumulative.InputTokens, | |
| 127 | + | "output": e.Cumulative.OutputTokens, | |
| 128 | + | "reasoning": nullableReasoningTokens(e.Cumulative), | |
| 129 | + | "cache_read": e.Cumulative.CacheReadTokens, | |
| 130 | + | "cache_write": e.Cumulative.CacheWriteTokens, | |
| 131 | + | "cache_measured_prompt": e.Cumulative.CacheMeasuredPromptTokens, | |
| 132 | + | "cache_measured_read": e.Cumulative.CacheMeasuredReadTokens, | |
| 133 | + | "cost_usd": e.Cumulative.CostUSD, | |
| 125 | 134 | } | |
| 126 | 135 | case core.EvTurnEnd: | |
| 127 | 136 | m["stop"] = string(e.Stop) | |
| @@ -82,6 +82,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 82 | case core.EvAssistantMessage: | 82 | case core.EvAssistantMessage: | ||
| 83 | m["content"] = ContentToJSON(e.Message.Content) | 83 | m["content"] = ContentToJSON(e.Message.Content) | ||
| 84 | m["time"] = e.Message.Time | 84 | m["time"] = e.Message.Time | ||
| 85 | + | case core.EvCacheDiagnostics: | |||
| 86 | + | m["eligible"] = e.Eligible | |||
| 87 | + | m["mode"] = e.Mode | |||
| 88 | + | m["transport"] = e.Transport | |||
| 89 | + | m["continuation"] = e.Continuation | |||
| 85 | case core.EvTextDelta: | 90 | case core.EvTextDelta: | ||
| 86 | m["delta"] = e.Delta | 91 | m["delta"] = e.Delta | ||
| 87 | case core.EvToolUseStart: | 92 | case core.EvToolUseStart: | ||
| @@ -114,14 +119,18 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 114 | m["reasoning"] = nullableReasoningTokens(e.Usage) | 119 | m["reasoning"] = nullableReasoningTokens(e.Usage) | ||
| 115 | m["cache_read"] = e.Usage.CacheReadTokens | 120 | m["cache_read"] = e.Usage.CacheReadTokens | ||
| 116 | m["cache_write"] = e.Usage.CacheWriteTokens | 121 | m["cache_write"] = e.Usage.CacheWriteTokens | ||
| 122 | + | m["cache_measured_prompt"] = e.Usage.CacheMeasuredPromptTokens | |||
| 123 | + | m["cache_measured_read"] = e.Usage.CacheMeasuredReadTokens | |||
| 117 | m["cost_usd"] = e.Usage.CostUSD | 124 | m["cost_usd"] = e.Usage.CostUSD | ||
| 118 | m["cumulative"] = map[string]any{ | 125 | m["cumulative"] = map[string]any{ | ||
| 119 | - | "input": e.Cumulative.InputTokens, | 126 | + | "input": e.Cumulative.InputTokens, |
| 120 | - | "output": e.Cumulative.OutputTokens, | 127 | + | "output": e.Cumulative.OutputTokens, |
| 121 | - | "reasoning": nullableReasoningTokens(e.Cumulative), | 128 | + | "reasoning": nullableReasoningTokens(e.Cumulative), |
| 122 | - | "cache_read": e.Cumulative.CacheReadTokens, | 129 | + | "cache_read": e.Cumulative.CacheReadTokens, |
| 123 | - | "cache_write": e.Cumulative.CacheWriteTokens, | 130 | + | "cache_write": e.Cumulative.CacheWriteTokens, |
| 124 | - | "cost_usd": e.Cumulative.CostUSD, | 131 | + | "cache_measured_prompt": e.Cumulative.CacheMeasuredPromptTokens, |
| 132 | + | "cache_measured_read": e.Cumulative.CacheMeasuredReadTokens, | |||
| 133 | + | "cost_usd": e.Cumulative.CostUSD, | |||
| 125 | } | 134 | } | ||
| 126 | case core.EvTurnEnd: | 135 | case core.EvTurnEnd: | ||
| 127 | m["stop"] = string(e.Stop) | 136 | m["stop"] = string(e.Stop) | ||
M packages/agent/modes/json_activity_test.go +14 -0
| @@ -69,6 +69,20 @@ func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) { | |||
| 69 | 69 | } | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) { | |
| 73 | + | event := EventToJSON(core.EvCacheDiagnostics{ | |
| 74 | + | Eligible: true, Mode: "implicit", Transport: "websocket", Continuation: "incremental", | |
| 75 | + | }) | |
| 76 | + | if event["type"] != "cache_diagnostics" || event["eligible"] != true || event["mode"] != "implicit" || event["transport"] != "websocket" || event["continuation"] != "incremental" { | |
| 77 | + | t.Fatalf("cache diagnostics event = %#v", event) | |
| 78 | + | } | |
| 79 | + | for _, forbidden := range []string{"prompt", "session_id", "thread_id", "request_body", "credential"} { | |
| 80 | + | if _, ok := event[forbidden]; ok { | |
| 81 | + | t.Fatalf("cache diagnostics leaked %q: %#v", forbidden, event) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 72 | 86 | func TestEventToJSONActivityLifecycle(t *testing.T) { | |
| 73 | 87 | request := EventToJSON(core.EvRequestStarted{ | |
| 74 | 88 | Provider: "anthropic", | |
| @@ -69,6 +69,20 @@ func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) { | |||||
| 69 | } | 69 | } | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) { | |||
| 73 | + | event := EventToJSON(core.EvCacheDiagnostics{ | |||
| 74 | + | Eligible: true, Mode: "implicit", Transport: "websocket", Continuation: "incremental", | |||
| 75 | + | }) | |||
| 76 | + | if event["type"] != "cache_diagnostics" || event["eligible"] != true || event["mode"] != "implicit" || event["transport"] != "websocket" || event["continuation"] != "incremental" { | |||
| 77 | + | t.Fatalf("cache diagnostics event = %#v", event) | |||
| 78 | + | } | |||
| 79 | + | for _, forbidden := range []string{"prompt", "session_id", "thread_id", "request_body", "credential"} { | |||
| 80 | + | if _, ok := event[forbidden]; ok { | |||
| 81 | + | t.Fatalf("cache diagnostics leaked %q: %#v", forbidden, event) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 72 | func TestEventToJSONActivityLifecycle(t *testing.T) { | 86 | func TestEventToJSONActivityLifecycle(t *testing.T) { | ||
| 73 | request := EventToJSON(core.EvRequestStarted{ | 87 | request := EventToJSON(core.EvRequestStarted{ | ||
| 74 | Provider: "anthropic", | 88 | Provider: "anthropic", | ||
M packages/agent/modes/resident_subagent_indicator_test.go +1 -1
| @@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) { | |||
| 31 | 31 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 32 | 32 | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |
| 33 | 33 | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | |
| 34 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}, | |
| 34 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, | |
| 35 | 35 | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |
| 36 | 36 | }}, 100, now) | |
| 37 | 37 | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | |
| @@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) { | |||||
| 31 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | 31 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | ||
| 32 | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | 32 | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | ||
| 33 | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | 33 | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | ||
| 34 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}, | 34 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, |
| 35 | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | 35 | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | ||
| 36 | }}, 100, now) | 36 | }}, 100, now) | ||
| 37 | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | 37 | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | ||
M packages/agent/modes/resident_subagents_dialog_test.go +1 -1
| @@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |||
| 35 | 35 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 36 | 36 | journal.ConfigureUsage(272_000, true) | |
| 37 | 37 | return func(context.Context, string) error { | |
| 38 | - | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | |
| 38 | + | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525} | |
| 39 | 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | |
| 40 | 40 | }, nil | |
| 41 | 41 | }) | |
| @@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |||||
| 35 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 35 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | ||
| 36 | journal.ConfigureUsage(272_000, true) | 36 | journal.ConfigureUsage(272_000, true) | ||
| 37 | return func(context.Context, string) error { | 37 | return func(context.Context, string) error { | ||
| 38 | - | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | 38 | + | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525} |
| 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | ||
| 40 | }, nil | 40 | }, nil | ||
| 41 | }) | 41 | }) | ||
M packages/agent/rpc.go +8 -6
| @@ -468,12 +468,14 @@ func (s *rpcServer) snapshotState() map[string]any { | |||
| 468 | 468 | "message_count": len(s.agent.Messages()), | |
| 469 | 469 | "busy": s.activeCancel != nil, | |
| 470 | 470 | "usage": map[string]any{ | |
| 471 | - | "input": cum.InputTokens, | |
| 472 | - | "output": cum.OutputTokens, | |
| 473 | - | "reasoning": rpcReasoningTokens(cum), | |
| 474 | - | "cache_read": cum.CacheReadTokens, | |
| 475 | - | "cache_write": cum.CacheWriteTokens, | |
| 476 | - | "cost_usd": cum.CostUSD, | |
| 471 | + | "input": cum.InputTokens, | |
| 472 | + | "output": cum.OutputTokens, | |
| 473 | + | "reasoning": rpcReasoningTokens(cum), | |
| 474 | + | "cache_read": cum.CacheReadTokens, | |
| 475 | + | "cache_write": cum.CacheWriteTokens, | |
| 476 | + | "cache_measured_prompt": cum.CacheMeasuredPromptTokens, | |
| 477 | + | "cache_measured_read": cum.CacheMeasuredReadTokens, | |
| 478 | + | "cost_usd": cum.CostUSD, | |
| 477 | 479 | }, | |
| 478 | 480 | } | |
| 479 | 481 | } | |
| @@ -468,12 +468,14 @@ func (s *rpcServer) snapshotState() map[string]any { | |||||
| 468 | "message_count": len(s.agent.Messages()), | 468 | "message_count": len(s.agent.Messages()), | ||
| 469 | "busy": s.activeCancel != nil, | 469 | "busy": s.activeCancel != nil, | ||
| 470 | "usage": map[string]any{ | 470 | "usage": map[string]any{ | ||
| 471 | - | "input": cum.InputTokens, | 471 | + | "input": cum.InputTokens, |
| 472 | - | "output": cum.OutputTokens, | 472 | + | "output": cum.OutputTokens, |
| 473 | - | "reasoning": rpcReasoningTokens(cum), | 473 | + | "reasoning": rpcReasoningTokens(cum), |
| 474 | - | "cache_read": cum.CacheReadTokens, | 474 | + | "cache_read": cum.CacheReadTokens, |
| 475 | - | "cache_write": cum.CacheWriteTokens, | 475 | + | "cache_write": cum.CacheWriteTokens, |
| 476 | - | "cost_usd": cum.CostUSD, | 476 | + | "cache_measured_prompt": cum.CacheMeasuredPromptTokens, |
| 477 | + | "cache_measured_read": cum.CacheMeasuredReadTokens, | |||
| 478 | + | "cost_usd": cum.CostUSD, | |||
| 477 | }, | 479 | }, | ||
| 478 | } | 480 | } | ||
| 479 | } | 481 | } | ||
M packages/agent/sdk/sdk.go +8 -6
| @@ -199,12 +199,14 @@ func (r *Runtime) Cost() Usage { | |||
| 199 | 199 | } | |
| 200 | 200 | u := r.agent.Cost() | |
| 201 | 201 | return Usage{ | |
| 202 | - | Input: u.InputTokens, | |
| 203 | - | Output: u.OutputTokens, | |
| 204 | - | Reasoning: sdkReasoningTokens(u), | |
| 205 | - | CacheRead: u.CacheReadTokens, | |
| 206 | - | CacheWrite: u.CacheWriteTokens, | |
| 207 | - | CostUSD: u.CostUSD, | |
| 202 | + | Input: u.InputTokens, | |
| 203 | + | Output: u.OutputTokens, | |
| 204 | + | Reasoning: sdkReasoningTokens(u), | |
| 205 | + | CacheRead: u.CacheReadTokens, | |
| 206 | + | CacheWrite: u.CacheWriteTokens, | |
| 207 | + | CacheMeasuredPrompt: u.CacheMeasuredPromptTokens, | |
| 208 | + | CacheMeasuredRead: u.CacheMeasuredReadTokens, | |
| 209 | + | CostUSD: u.CostUSD, | |
| 208 | 210 | } | |
| 209 | 211 | } | |
| 210 | 212 | ||
| @@ -199,12 +199,14 @@ func (r *Runtime) Cost() Usage { | |||||
| 199 | } | 199 | } | ||
| 200 | u := r.agent.Cost() | 200 | u := r.agent.Cost() | ||
| 201 | return Usage{ | 201 | return Usage{ | ||
| 202 | - | Input: u.InputTokens, | 202 | + | Input: u.InputTokens, |
| 203 | - | Output: u.OutputTokens, | 203 | + | Output: u.OutputTokens, |
| 204 | - | Reasoning: sdkReasoningTokens(u), | 204 | + | Reasoning: sdkReasoningTokens(u), |
| 205 | - | CacheRead: u.CacheReadTokens, | 205 | + | CacheRead: u.CacheReadTokens, |
| 206 | - | CacheWrite: u.CacheWriteTokens, | 206 | + | CacheWrite: u.CacheWriteTokens, |
| 207 | - | CostUSD: u.CostUSD, | 207 | + | CacheMeasuredPrompt: u.CacheMeasuredPromptTokens, |
| 208 | + | CacheMeasuredRead: u.CacheMeasuredReadTokens, | |||
| 209 | + | CostUSD: u.CostUSD, | |||
| 208 | } | 210 | } | ||
| 209 | } | 211 | } | ||
| 210 | 212 | ||||
M packages/agent/sdk/types.go +42 -18
| @@ -46,6 +46,9 @@ type Event struct { | |||
| 46 | 46 | Usage *Usage `json:"usage,omitempty"` | |
| 47 | 47 | Cumulative *Usage `json:"cumulative,omitempty"` | |
| 48 | 48 | ||
| 49 | + | // cache_diagnostics | |
| 50 | + | CacheDiagnostics *CacheDiagnostics `json:"cache_diagnostics,omitempty"` | |
| 51 | + | ||
| 49 | 52 | // user_message / assistant_message | |
| 50 | 53 | Message *Message `json:"message,omitempty"` | |
| 51 | 54 | ||
| @@ -90,14 +93,24 @@ type Image struct { | |||
| 90 | 93 | Data []byte | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 96 | + | // CacheDiagnostics reports sanitized provider cache-routing state. | |
| 97 | + | type CacheDiagnostics struct { | |
| 98 | + | Eligible bool `json:"eligible"` | |
| 99 | + | Mode string `json:"mode"` | |
| 100 | + | Transport string `json:"transport"` | |
| 101 | + | Continuation string `json:"continuation"` | |
| 102 | + | } | |
| 103 | + | ||
| 93 | 104 | // Usage is per-turn or cumulative token / cost counts. | |
| 94 | 105 | type Usage struct { | |
| 95 | - | Input int `json:"input"` | |
| 96 | - | Output int `json:"output"` | |
| 97 | - | Reasoning *int `json:"reasoning"` | |
| 98 | - | CacheRead int `json:"cache_read"` | |
| 99 | - | CacheWrite int `json:"cache_write"` | |
| 100 | - | CostUSD float64 `json:"cost_usd"` | |
| 106 | + | Input int `json:"input"` | |
| 107 | + | Output int `json:"output"` | |
| 108 | + | Reasoning *int `json:"reasoning"` | |
| 109 | + | CacheRead int `json:"cache_read"` | |
| 110 | + | CacheWrite int `json:"cache_write"` | |
| 111 | + | CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"` | |
| 112 | + | CacheMeasuredRead int `json:"cache_measured_read,omitempty"` | |
| 113 | + | CostUSD float64 `json:"cost_usd"` | |
| 101 | 114 | } | |
| 102 | 115 | ||
| 103 | 116 | // State is a snapshot of the runtime's current state. | |
| @@ -180,22 +193,33 @@ func toEvent(ev core.AgentEvent) Event { | |||
| 180 | 193 | out.ID = e.ID | |
| 181 | 194 | out.IsError = e.Result.IsError | |
| 182 | 195 | out.Result = convertContent(e.Result.Content) | |
| 196 | + | case core.EvCacheDiagnostics: | |
| 197 | + | out.CacheDiagnostics = &CacheDiagnostics{ | |
| 198 | + | Eligible: e.Eligible, | |
| 199 | + | Mode: e.Mode, | |
| 200 | + | Transport: e.Transport, | |
| 201 | + | Continuation: e.Continuation, | |
| 202 | + | } | |
| 183 | 203 | case core.EvUsage: | |
| 184 | 204 | out.Usage = &Usage{ | |
| 185 | - | Input: e.Usage.InputTokens, | |
| 186 | - | Output: e.Usage.OutputTokens, | |
| 187 | - | Reasoning: sdkReasoningTokens(e.Usage), | |
| 188 | - | CacheRead: e.Usage.CacheReadTokens, | |
| 189 | - | CacheWrite: e.Usage.CacheWriteTokens, | |
| 190 | - | CostUSD: e.Usage.CostUSD, | |
| 205 | + | Input: e.Usage.InputTokens, | |
| 206 | + | Output: e.Usage.OutputTokens, | |
| 207 | + | Reasoning: sdkReasoningTokens(e.Usage), | |
| 208 | + | CacheRead: e.Usage.CacheReadTokens, | |
| 209 | + | CacheWrite: e.Usage.CacheWriteTokens, | |
| 210 | + | CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens, | |
| 211 | + | CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens, | |
| 212 | + | CostUSD: e.Usage.CostUSD, | |
| 191 | 213 | } | |
| 192 | 214 | out.Cumulative = &Usage{ | |
| 193 | - | Input: e.Cumulative.InputTokens, | |
| 194 | - | Output: e.Cumulative.OutputTokens, | |
| 195 | - | Reasoning: sdkReasoningTokens(e.Cumulative), | |
| 196 | - | CacheRead: e.Cumulative.CacheReadTokens, | |
| 197 | - | CacheWrite: e.Cumulative.CacheWriteTokens, | |
| 198 | - | CostUSD: e.Cumulative.CostUSD, | |
| 215 | + | Input: e.Cumulative.InputTokens, | |
| 216 | + | Output: e.Cumulative.OutputTokens, | |
| 217 | + | Reasoning: sdkReasoningTokens(e.Cumulative), | |
| 218 | + | CacheRead: e.Cumulative.CacheReadTokens, | |
| 219 | + | CacheWrite: e.Cumulative.CacheWriteTokens, | |
| 220 | + | CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens, | |
| 221 | + | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | |
| 222 | + | CostUSD: e.Cumulative.CostUSD, | |
| 199 | 223 | } | |
| 200 | 224 | case core.EvTurnEnd: | |
| 201 | 225 | out.Stop = string(e.Stop) | |
| @@ -46,6 +46,9 @@ type Event struct { | |||||
| 46 | Usage *Usage `json:"usage,omitempty"` | 46 | Usage *Usage `json:"usage,omitempty"` | ||
| 47 | Cumulative *Usage `json:"cumulative,omitempty"` | 47 | Cumulative *Usage `json:"cumulative,omitempty"` | ||
| 48 | 48 | ||||
| 49 | + | // cache_diagnostics | |||
| 50 | + | CacheDiagnostics *CacheDiagnostics `json:"cache_diagnostics,omitempty"` | |||
| 51 | + | ||||
| 49 | // user_message / assistant_message | 52 | // user_message / assistant_message | ||
| 50 | Message *Message `json:"message,omitempty"` | 53 | Message *Message `json:"message,omitempty"` | ||
| 51 | 54 | ||||
| @@ -90,14 +93,24 @@ type Image struct { | |||||
| 90 | Data []byte | 93 | Data []byte | ||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 96 | + | // CacheDiagnostics reports sanitized provider cache-routing state. | |||
| 97 | + | type CacheDiagnostics struct { | |||
| 98 | + | Eligible bool `json:"eligible"` | |||
| 99 | + | Mode string `json:"mode"` | |||
| 100 | + | Transport string `json:"transport"` | |||
| 101 | + | Continuation string `json:"continuation"` | |||
| 102 | + | } | |||
| 103 | + | ||||
| 93 | // Usage is per-turn or cumulative token / cost counts. | 104 | // Usage is per-turn or cumulative token / cost counts. | ||
| 94 | type Usage struct { | 105 | type Usage struct { | ||
| 95 | - | Input int `json:"input"` | 106 | + | Input int `json:"input"` |
| 96 | - | Output int `json:"output"` | 107 | + | Output int `json:"output"` |
| 97 | - | Reasoning *int `json:"reasoning"` | 108 | + | Reasoning *int `json:"reasoning"` |
| 98 | - | CacheRead int `json:"cache_read"` | 109 | + | CacheRead int `json:"cache_read"` |
| 99 | - | CacheWrite int `json:"cache_write"` | 110 | + | CacheWrite int `json:"cache_write"` |
| 100 | - | CostUSD float64 `json:"cost_usd"` | 111 | + | CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"` |
| 112 | + | CacheMeasuredRead int `json:"cache_measured_read,omitempty"` | |||
| 113 | + | CostUSD float64 `json:"cost_usd"` | |||
| 101 | } | 114 | } | ||
| 102 | 115 | ||||
| 103 | // State is a snapshot of the runtime's current state. | 116 | // State is a snapshot of the runtime's current state. | ||
| @@ -180,22 +193,33 @@ func toEvent(ev core.AgentEvent) Event { | |||||
| 180 | out.ID = e.ID | 193 | out.ID = e.ID | ||
| 181 | out.IsError = e.Result.IsError | 194 | out.IsError = e.Result.IsError | ||
| 182 | out.Result = convertContent(e.Result.Content) | 195 | out.Result = convertContent(e.Result.Content) | ||
| 196 | + | case core.EvCacheDiagnostics: | |||
| 197 | + | out.CacheDiagnostics = &CacheDiagnostics{ | |||
| 198 | + | Eligible: e.Eligible, | |||
| 199 | + | Mode: e.Mode, | |||
| 200 | + | Transport: e.Transport, | |||
| 201 | + | Continuation: e.Continuation, | |||
| 202 | + | } | |||
| 183 | case core.EvUsage: | 203 | case core.EvUsage: | ||
| 184 | out.Usage = &Usage{ | 204 | out.Usage = &Usage{ | ||
| 185 | - | Input: e.Usage.InputTokens, | 205 | + | Input: e.Usage.InputTokens, |
| 186 | - | Output: e.Usage.OutputTokens, | 206 | + | Output: e.Usage.OutputTokens, |
| 187 | - | Reasoning: sdkReasoningTokens(e.Usage), | 207 | + | Reasoning: sdkReasoningTokens(e.Usage), |
| 188 | - | CacheRead: e.Usage.CacheReadTokens, | 208 | + | CacheRead: e.Usage.CacheReadTokens, |
| 189 | - | CacheWrite: e.Usage.CacheWriteTokens, | 209 | + | CacheWrite: e.Usage.CacheWriteTokens, |
| 190 | - | CostUSD: e.Usage.CostUSD, | 210 | + | CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens, |
| 211 | + | CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens, | |||
| 212 | + | CostUSD: e.Usage.CostUSD, | |||
| 191 | } | 213 | } | ||
| 192 | out.Cumulative = &Usage{ | 214 | out.Cumulative = &Usage{ | ||
| 193 | - | Input: e.Cumulative.InputTokens, | 215 | + | Input: e.Cumulative.InputTokens, |
| 194 | - | Output: e.Cumulative.OutputTokens, | 216 | + | Output: e.Cumulative.OutputTokens, |
| 195 | - | Reasoning: sdkReasoningTokens(e.Cumulative), | 217 | + | Reasoning: sdkReasoningTokens(e.Cumulative), |
| 196 | - | CacheRead: e.Cumulative.CacheReadTokens, | 218 | + | CacheRead: e.Cumulative.CacheReadTokens, |
| 197 | - | CacheWrite: e.Cumulative.CacheWriteTokens, | 219 | + | CacheWrite: e.Cumulative.CacheWriteTokens, |
| 198 | - | CostUSD: e.Cumulative.CostUSD, | 220 | + | CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens, |
| 221 | + | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | |||
| 222 | + | CostUSD: e.Cumulative.CostUSD, | |||
| 199 | } | 223 | } | ||
| 200 | case core.EvTurnEnd: | 224 | case core.EvTurnEnd: | ||
| 201 | out.Stop = string(e.Stop) | 225 | out.Stop = string(e.Stop) | ||
M packages/agent/sdk/types_test.go +27 -0
| @@ -2,12 +2,39 @@ package sdk | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/zut/packages/core" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | func TestToEventPreservesCacheDiagnostics(t *testing.T) { | |
| 13 | + | for _, diagnostics := range []core.EvCacheDiagnostics{ | |
| 14 | + | {Eligible: true, Mode: "automatic", Transport: "http_sse", Continuation: "full"}, | |
| 15 | + | {Eligible: false, Mode: "below_minimum", Transport: "websocket", Continuation: "incremental"}, | |
| 16 | + | } { | |
| 17 | + | event := toEvent(diagnostics) | |
| 18 | + | if event.Type != "cache_diagnostics" || event.CacheDiagnostics == nil { | |
| 19 | + | t.Fatalf("event = %#v", event) | |
| 20 | + | } | |
| 21 | + | got := event.CacheDiagnostics | |
| 22 | + | if got.Eligible != diagnostics.Eligible || got.Mode != diagnostics.Mode || got.Transport != diagnostics.Transport || got.Continuation != diagnostics.Continuation { | |
| 23 | + | t.Fatalf("cache diagnostics = %#v, want %#v", got, diagnostics) | |
| 24 | + | } | |
| 25 | + | encoded, err := json.Marshal(event) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if !json.Valid(encoded) { | |
| 30 | + | t.Fatalf("encoded diagnostics are invalid JSON: %s", encoded) | |
| 31 | + | } | |
| 32 | + | if !diagnostics.Eligible && !strings.Contains(string(encoded), `"eligible":false`) { | |
| 33 | + | t.Fatalf("encoded ineligible diagnostics = %s, want eligible=false", encoded) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 11 | 38 | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | |
| 12 | 39 | retry := toEvent(core.EvRetryScheduled{ | |
| 13 | 40 | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | |
| @@ -2,12 +2,39 @@ package sdk | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/zut/packages/core" | 9 | "github.com/bnema/zut/packages/core" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | func TestToEventPreservesCacheDiagnostics(t *testing.T) { | |||
| 13 | + | for _, diagnostics := range []core.EvCacheDiagnostics{ | |||
| 14 | + | {Eligible: true, Mode: "automatic", Transport: "http_sse", Continuation: "full"}, | |||
| 15 | + | {Eligible: false, Mode: "below_minimum", Transport: "websocket", Continuation: "incremental"}, | |||
| 16 | + | } { | |||
| 17 | + | event := toEvent(diagnostics) | |||
| 18 | + | if event.Type != "cache_diagnostics" || event.CacheDiagnostics == nil { | |||
| 19 | + | t.Fatalf("event = %#v", event) | |||
| 20 | + | } | |||
| 21 | + | got := event.CacheDiagnostics | |||
| 22 | + | if got.Eligible != diagnostics.Eligible || got.Mode != diagnostics.Mode || got.Transport != diagnostics.Transport || got.Continuation != diagnostics.Continuation { | |||
| 23 | + | t.Fatalf("cache diagnostics = %#v, want %#v", got, diagnostics) | |||
| 24 | + | } | |||
| 25 | + | encoded, err := json.Marshal(event) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | if !json.Valid(encoded) { | |||
| 30 | + | t.Fatalf("encoded diagnostics are invalid JSON: %s", encoded) | |||
| 31 | + | } | |||
| 32 | + | if !diagnostics.Eligible && !strings.Contains(string(encoded), `"eligible":false`) { | |||
| 33 | + | t.Fatalf("encoded ineligible diagnostics = %s, want eligible=false", encoded) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 11 | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | 38 | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | ||
| 12 | retry := toEvent(core.EvRetryScheduled{ | 39 | retry := toEvent(core.EvRetryScheduled{ | ||
| 13 | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | 40 | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | ||
M packages/agent/subagent_factory.go +11 -2
| @@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 47 | 47 | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 48 | 48 | agent.SeedCost(baseline.Usage) | |
| 49 | 49 | } | |
| 50 | - | if err := agent.BindSessionID(spec.SessionID); err != nil { | |
| 51 | - | return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err) | |
| 50 | + | rootCacheID := strings.TrimSpace(spec.RootCacheID) | |
| 51 | + | if rootCacheID == "" { | |
| 52 | + | // Journals written before root cache identity was persisted can still | |
| 53 | + | // resume safely: their immediate parent was the only available root. | |
| 54 | + | rootCacheID = strings.TrimSpace(spec.ParentSessionID) | |
| 55 | + | if rootCacheID == "" { | |
| 56 | + | rootCacheID = spec.SessionID | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil { | |
| 60 | + | return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err) | |
| 52 | 61 | } | |
| 53 | 62 | if journal != nil { | |
| 54 | 63 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |
| @@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 47 | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | 47 | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | ||
| 48 | agent.SeedCost(baseline.Usage) | 48 | agent.SeedCost(baseline.Usage) | ||
| 49 | } | 49 | } | ||
| 50 | - | if err := agent.BindSessionID(spec.SessionID); err != nil { | 50 | + | rootCacheID := strings.TrimSpace(spec.RootCacheID) |
| 51 | - | return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err) | 51 | + | if rootCacheID == "" { |
| 52 | + | // Journals written before root cache identity was persisted can still | |||
| 53 | + | // resume safely: their immediate parent was the only available root. | |||
| 54 | + | rootCacheID = strings.TrimSpace(spec.ParentSessionID) | |||
| 55 | + | if rootCacheID == "" { | |||
| 56 | + | rootCacheID = spec.SessionID | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil { | |||
| 60 | + | return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err) | |||
| 52 | } | 61 | } | ||
| 53 | if journal != nil { | 62 | if journal != nil { | ||
| 54 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | 63 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | ||
M packages/agent/subagent_runtime.go +1 -1
| @@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 338 | 338 | workspaceMode = subagents.WorkspaceShared | |
| 339 | 339 | } | |
| 340 | 340 | spec := subagents.ResidentChildSpec{ | |
| 341 | - | ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession, | |
| 341 | + | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |
| 342 | 342 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 343 | 343 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |
| 344 | 344 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| @@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 338 | workspaceMode = subagents.WorkspaceShared | 338 | workspaceMode = subagents.WorkspaceShared | ||
| 339 | } | 339 | } | ||
| 340 | spec := subagents.ResidentChildSpec{ | 340 | spec := subagents.ResidentChildSpec{ | ||
| 341 | - | ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession, | 341 | + | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, |
| 342 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | 342 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | ||
| 343 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | 343 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | ||
| 344 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | 344 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | ||
M packages/agent/subagents/resident_journal.go +7 -3
| @@ -65,8 +65,11 @@ const ( | |||
| 65 | 65 | // provider endpoint and TLS mode are retained so explicit resume reconstructs | |
| 66 | 66 | // the same transport contract. | |
| 67 | 67 | type ResidentChildSpec struct { | |
| 68 | - | ID string `json:"id"` | |
| 68 | + | ID string `json:"id"` | |
| 69 | + | // SessionID identifies the child's conversation thread. RootCacheID is the | |
| 70 | + | // root cache affinity shared by every child of the same parent session. | |
| 69 | 71 | SessionID string `json:"session_id"` | |
| 72 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |
| 70 | 73 | InitialTurnID string `json:"initial_turn_id,omitempty"` | |
| 71 | 74 | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 72 | 75 | Profile string `json:"profile,omitempty"` | |
| @@ -193,6 +196,7 @@ type ResidentMetadata struct { | |||
| 193 | 196 | Version int `json:"version"` | |
| 194 | 197 | ID string `json:"id"` | |
| 195 | 198 | SessionID string `json:"session_id"` | |
| 199 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |
| 196 | 200 | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 197 | 201 | State ResidentState `json:"state"` | |
| 198 | 202 | UpdatedAt time.Time `json:"updated_at"` | |
| @@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||
| 491 | 495 | usage := j.usageSnapshot() | |
| 492 | 496 | return ResidentMetadata{ | |
| 493 | 497 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 494 | - | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | |
| 498 | + | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | |
| 495 | 499 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 496 | 500 | } | |
| 497 | 501 | } | |
| @@ -779,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 779 | 783 | } | |
| 780 | 784 | metadata := ResidentMetadata{ | |
| 781 | 785 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 782 | - | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | |
| 786 | + | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | |
| 783 | 787 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 784 | 788 | } | |
| 785 | 789 | needsInterruption := state == ResidentQueued || state == ResidentRunning | |
| @@ -65,8 +65,11 @@ const ( | |||||
| 65 | // provider endpoint and TLS mode are retained so explicit resume reconstructs | 65 | // provider endpoint and TLS mode are retained so explicit resume reconstructs | ||
| 66 | // the same transport contract. | 66 | // the same transport contract. | ||
| 67 | type ResidentChildSpec struct { | 67 | type ResidentChildSpec struct { | ||
| 68 | - | ID string `json:"id"` | 68 | + | ID string `json:"id"` |
| 69 | + | // SessionID identifies the child's conversation thread. RootCacheID is the | |||
| 70 | + | // root cache affinity shared by every child of the same parent session. | |||
| 69 | SessionID string `json:"session_id"` | 71 | SessionID string `json:"session_id"` | ||
| 72 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |||
| 70 | InitialTurnID string `json:"initial_turn_id,omitempty"` | 73 | InitialTurnID string `json:"initial_turn_id,omitempty"` | ||
| 71 | ParentSessionID string `json:"parent_session_id,omitempty"` | 74 | ParentSessionID string `json:"parent_session_id,omitempty"` | ||
| 72 | Profile string `json:"profile,omitempty"` | 75 | Profile string `json:"profile,omitempty"` | ||
| @@ -193,6 +196,7 @@ type ResidentMetadata struct { | |||||
| 193 | Version int `json:"version"` | 196 | Version int `json:"version"` | ||
| 194 | ID string `json:"id"` | 197 | ID string `json:"id"` | ||
| 195 | SessionID string `json:"session_id"` | 198 | SessionID string `json:"session_id"` | ||
| 199 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |||
| 196 | ParentSessionID string `json:"parent_session_id,omitempty"` | 200 | ParentSessionID string `json:"parent_session_id,omitempty"` | ||
| 197 | State ResidentState `json:"state"` | 201 | State ResidentState `json:"state"` | ||
| 198 | UpdatedAt time.Time `json:"updated_at"` | 202 | UpdatedAt time.Time `json:"updated_at"` | ||
| @@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||||
| 491 | usage := j.usageSnapshot() | 495 | usage := j.usageSnapshot() | ||
| 492 | return ResidentMetadata{ | 496 | return ResidentMetadata{ | ||
| 493 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | 497 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | ||
| 494 | - | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | 498 | + | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, |
| 495 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | 499 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | ||
| 496 | } | 500 | } | ||
| 497 | } | 501 | } | ||
| @@ -779,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 779 | } | 783 | } | ||
| 780 | metadata := ResidentMetadata{ | 784 | metadata := ResidentMetadata{ | ||
| 781 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | 785 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | ||
| 782 | - | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | 786 | + | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, |
| 783 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | 787 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | ||
| 784 | } | 788 | } | ||
| 785 | needsInterruption := state == ResidentQueued || state == ResidentRunning | 789 | needsInterruption := state == ResidentQueued || state == ResidentRunning | ||
M packages/agent/subagents/resident_journal_test.go +25 -2
| @@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 25 | 25 | spec := ResidentChildSpec{ | |
| 26 | 26 | ID: "child-1", | |
| 27 | 27 | SessionID: "child-session-1", | |
| 28 | + | RootCacheID: "root-cache-1", | |
| 28 | 29 | ParentSessionID: "parent-session-1", | |
| 29 | 30 | Provider: "openai-codex", | |
| 30 | 31 | Model: "gpt-5.6-terra", | |
| @@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 42 | 43 | if len(records) != 1 || records[0].Type != residentRecordAccepted { | |
| 43 | 44 | t.Fatalf("records = %#v, want one accepted record", records) | |
| 44 | 45 | } | |
| 45 | - | if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" { | |
| 46 | + | if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" { | |
| 46 | 47 | t.Fatalf("accepted record = %#v", records[0]) | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 50 | 51 | if err != nil { | |
| 51 | 52 | t.Fatalf("ReadResidentMetadata: %v", err) | |
| 52 | 53 | } | |
| 53 | - | if meta.State != ResidentQueued || meta.SessionID != spec.SessionID { | |
| 54 | + | if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID { | |
| 54 | 55 | t.Fatalf("metadata = %#v", meta) | |
| 55 | 56 | } | |
| 56 | 57 | info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName)) | |
| @@ -92,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) { | |||
| 92 | 93 | } | |
| 93 | 94 | } | |
| 94 | 95 | ||
| 96 | + | func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) { | |
| 97 | + | root := t.TempDir() | |
| 98 | + | journal, err := OpenResidentJournal(root, "cache-child") | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"} | |
| 103 | + | if err := journal.Accept(spec, "task"); err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if err := journal.Close(); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | if metadata.RootCacheID != spec.RootCacheID { | |
| 114 | + | t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 95 | 118 | func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) { | |
| 96 | 119 | root := t.TempDir() | |
| 97 | 120 | journal, err := OpenResidentJournal(root, "interrupted-usage") | |
| @@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||||
| 25 | spec := ResidentChildSpec{ | 25 | spec := ResidentChildSpec{ | ||
| 26 | ID: "child-1", | 26 | ID: "child-1", | ||
| 27 | SessionID: "child-session-1", | 27 | SessionID: "child-session-1", | ||
| 28 | + | RootCacheID: "root-cache-1", | |||
| 28 | ParentSessionID: "parent-session-1", | 29 | ParentSessionID: "parent-session-1", | ||
| 29 | Provider: "openai-codex", | 30 | Provider: "openai-codex", | ||
| 30 | Model: "gpt-5.6-terra", | 31 | Model: "gpt-5.6-terra", | ||
| @@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||||
| 42 | if len(records) != 1 || records[0].Type != residentRecordAccepted { | 43 | if len(records) != 1 || records[0].Type != residentRecordAccepted { | ||
| 43 | t.Fatalf("records = %#v, want one accepted record", records) | 44 | t.Fatalf("records = %#v, want one accepted record", records) | ||
| 44 | } | 45 | } | ||
| 45 | - | if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" { | 46 | + | if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" { |
| 46 | t.Fatalf("accepted record = %#v", records[0]) | 47 | t.Fatalf("accepted record = %#v", records[0]) | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||||
| 50 | if err != nil { | 51 | if err != nil { | ||
| 51 | t.Fatalf("ReadResidentMetadata: %v", err) | 52 | t.Fatalf("ReadResidentMetadata: %v", err) | ||
| 52 | } | 53 | } | ||
| 53 | - | if meta.State != ResidentQueued || meta.SessionID != spec.SessionID { | 54 | + | if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID { |
| 54 | t.Fatalf("metadata = %#v", meta) | 55 | t.Fatalf("metadata = %#v", meta) | ||
| 55 | } | 56 | } | ||
| 56 | info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName)) | 57 | info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName)) | ||
| @@ -92,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) { | |||||
| 92 | } | 93 | } | ||
| 93 | } | 94 | } | ||
| 94 | 95 | ||||
| 96 | + | func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) { | |||
| 97 | + | root := t.TempDir() | |||
| 98 | + | journal, err := OpenResidentJournal(root, "cache-child") | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"} | |||
| 103 | + | if err := journal.Accept(spec, "task"); err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if err := journal.Close(); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |||
| 110 | + | if err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | if metadata.RootCacheID != spec.RootCacheID { | |||
| 114 | + | t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 95 | func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) { | 118 | func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) { | ||
| 96 | root := t.TempDir() | 119 | root := t.TempDir() | ||
| 97 | journal, err := OpenResidentJournal(root, "interrupted-usage") | 120 | journal, err := OpenResidentJournal(root, "interrupted-usage") | ||
M packages/agent/subagents/resident_manager_test.go +5 -4
| @@ -792,10 +792,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||
| 792 | 792 | }, nil | |
| 793 | 793 | }) | |
| 794 | 794 | defer manager.Close(context.Background()) | |
| 795 | - | for _, id := range []string{"first", "second"} { | |
| 796 | - | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: id, SessionID: id, Provider: "openai", Model: "gpt-5"}, id); err != nil { | |
| 797 | - | t.Fatalf("Spawn(%s): %v", id, err) | |
| 798 | - | } | |
| 795 | + | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil { | |
| 796 | + | t.Fatalf("Spawn(first): %v", err) | |
| 799 | 797 | } | |
| 800 | 798 | select { | |
| 801 | 799 | case got := <-started: | |
| @@ -805,6 +803,9 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||
| 805 | 803 | case <-time.After(time.Second): | |
| 806 | 804 | t.Fatal("first runner did not start") | |
| 807 | 805 | } | |
| 806 | + | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "second", SessionID: "second", Provider: "openai", Model: "gpt-5"}, "second"); err != nil { | |
| 807 | + | t.Fatalf("Spawn(second): %v", err) | |
| 808 | + | } | |
| 808 | 809 | deadline := time.Now().Add(time.Second) | |
| 809 | 810 | for { | |
| 810 | 811 | result, err := manager.Result("second") | |
| @@ -792,10 +792,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||||
| 792 | }, nil | 792 | }, nil | ||
| 793 | }) | 793 | }) | ||
| 794 | defer manager.Close(context.Background()) | 794 | defer manager.Close(context.Background()) | ||
| 795 | - | for _, id := range []string{"first", "second"} { | 795 | + | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil { |
| 796 | - | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: id, SessionID: id, Provider: "openai", Model: "gpt-5"}, id); err != nil { | 796 | + | t.Fatalf("Spawn(first): %v", err) |
| 797 | - | t.Fatalf("Spawn(%s): %v", id, err) | |||
| 798 | - | } | |||
| 799 | } | 797 | } | ||
| 800 | select { | 798 | select { | ||
| 801 | case got := <-started: | 799 | case got := <-started: | ||
| @@ -805,6 +803,9 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||||
| 805 | case <-time.After(time.Second): | 803 | case <-time.After(time.Second): | ||
| 806 | t.Fatal("first runner did not start") | 804 | t.Fatal("first runner did not start") | ||
| 807 | } | 805 | } | ||
| 806 | + | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "second", SessionID: "second", Provider: "openai", Model: "gpt-5"}, "second"); err != nil { | |||
| 807 | + | t.Fatalf("Spawn(second): %v", err) | |||
| 808 | + | } | |||
| 808 | deadline := time.Now().Add(time.Second) | 809 | deadline := time.Now().Add(time.Second) | ||
| 809 | for { | 810 | for { | ||
| 810 | result, err := manager.Result("second") | 811 | result, err := manager.Result("second") | ||
M packages/core/agent.go +60 -26
| @@ -164,12 +164,15 @@ type Agent struct { | |||
| 164 | 164 | // boundaries: before the next model call after a tool batch, or | |
| 165 | 165 | // after a text-only assistant turn finishes. It never interrupts | |
| 166 | 166 | // a running tool or cancels an in-flight provider request. | |
| 167 | - | queued []queuedMessage | |
| 168 | - | timeContext agentTimeContext | |
| 169 | - | // sessionID is assigned by the host from the durable session before the | |
| 170 | - | // first provider request. Agents without persistence allocate one lazily at | |
| 171 | - | // their first accepted turn and retain it for the process lifetime. | |
| 167 | + | queued []queuedMessage | |
| 168 | + | timeContext agentTimeContext | |
| 169 | + | hasSessionTimeContext bool | |
| 170 | + | // sessionID identifies this conversation thread. cacheSessionID identifies | |
| 171 | + | // the root prompt-cache affinity and may be shared by resident child | |
| 172 | + | // threads. Hosts bind both before the first provider request; unbound | |
| 173 | + | // agents allocate one shared ephemeral value for the process lifetime. | |
| 172 | 174 | sessionID string | |
| 175 | + | cacheSessionID string | |
| 173 | 176 | providerRequestStarted bool | |
| 174 | 177 | } | |
| 175 | 178 | ||
| @@ -188,37 +191,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age | |||
| 188 | 191 | } | |
| 189 | 192 | } | |
| 190 | 193 | ||
| 191 | - | // BindSessionID assigns the stable logical session identity before the first | |
| 192 | - | // provider request. A durable host session must bind its SessionMeta.ID here; | |
| 193 | - | // no-session hosts may leave it unset and the agent will allocate one lazily. | |
| 194 | + | // BindSessionID binds a root conversation's durable session as both its | |
| 195 | + | // thread identity and prompt-cache affinity. Resident children use | |
| 196 | + | // BindRequestIdentity to retain their own thread while sharing their root's | |
| 197 | + | // cache affinity. | |
| 194 | 198 | func (a *Agent) BindSessionID(id string) error { | |
| 195 | - | id = strings.TrimSpace(id) | |
| 196 | - | if id == "" { | |
| 197 | - | return errors.New("session ID is empty") | |
| 199 | + | return a.BindRequestIdentity(id, id) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // BindRequestIdentity assigns the stable root cache and thread identities | |
| 203 | + | // before the first provider request. | |
| 204 | + | func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error { | |
| 205 | + | cacheSessionID = strings.TrimSpace(cacheSessionID) | |
| 206 | + | threadID = strings.TrimSpace(threadID) | |
| 207 | + | if cacheSessionID == "" || threadID == "" { | |
| 208 | + | return errors.New("cache session ID and thread ID are required") | |
| 198 | 209 | } | |
| 199 | 210 | a.mu.Lock() | |
| 200 | 211 | defer a.mu.Unlock() | |
| 201 | 212 | if a.providerRequestStarted { | |
| 202 | - | return errors.New("cannot change session ID after the first provider request") | |
| 213 | + | return errors.New("cannot change request identity after the first provider request") | |
| 203 | 214 | } | |
| 204 | - | a.sessionID = id | |
| 215 | + | a.cacheSessionID = cacheSessionID | |
| 216 | + | a.sessionID = threadID | |
| 205 | 217 | return nil | |
| 206 | 218 | } | |
| 207 | 219 | ||
| 208 | - | // SessionID returns the stable logical session identity, allocating an | |
| 220 | + | // SessionID returns the stable conversation-thread identity, allocating an | |
| 209 | 221 | // ephemeral identity when persistence is disabled and the host did not bind | |
| 210 | 222 | // one during runtime assembly. | |
| 211 | 223 | func (a *Agent) SessionID() string { | |
| 212 | 224 | a.mu.Lock() | |
| 213 | 225 | defer a.mu.Unlock() | |
| 226 | + | a.ensureRequestIdentityLocked() | |
| 227 | + | return a.sessionID | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // CacheSessionID returns the stable root cache-affinity identity. | |
| 231 | + | func (a *Agent) CacheSessionID() string { | |
| 232 | + | a.mu.Lock() | |
| 233 | + | defer a.mu.Unlock() | |
| 234 | + | a.ensureRequestIdentityLocked() | |
| 235 | + | return a.cacheSessionID | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (a *Agent) ensureRequestIdentityLocked() { | |
| 214 | 239 | if a.sessionID == "" { | |
| 215 | 240 | a.sessionID = uuid.NewString() | |
| 216 | 241 | } | |
| 217 | - | return a.sessionID | |
| 242 | + | if a.cacheSessionID == "" { | |
| 243 | + | a.cacheSessionID = a.sessionID | |
| 244 | + | } | |
| 218 | 245 | } | |
| 219 | 246 | ||
| 220 | 247 | func (a *Agent) beginTurn() provider.RequestContext { | |
| 221 | - | return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()} | |
| 248 | + | a.mu.Lock() | |
| 249 | + | a.ensureRequestIdentityLocked() | |
| 250 | + | context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()} | |
| 251 | + | a.mu.Unlock() | |
| 252 | + | return context | |
| 222 | 253 | } | |
| 223 | 254 | ||
| 224 | 255 | func (a *Agent) beginProviderRequest() { | |
| @@ -593,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 593 | 624 | } | |
| 594 | 625 | } | |
| 595 | 626 | ||
| 627 | + | a.appendDynamicContext(turnContext) | |
| 628 | + | ||
| 596 | 629 | var ( | |
| 597 | 630 | stop provider.StopReason | |
| 598 | 631 | assistantMsg provider.Message | |
| @@ -866,6 +899,15 @@ type requestLifecycleSink struct { | |||
| 866 | 899 | lastReason RetryReason | |
| 867 | 900 | } | |
| 868 | 901 | ||
| 902 | + | func (s *requestLifecycleSink) CacheDiagnostics(diagnostics provider.CacheDiagnostics) { | |
| 903 | + | s.sink(EvCacheDiagnostics{ | |
| 904 | + | Eligible: diagnostics.Eligible, | |
| 905 | + | Mode: diagnostics.Mode, | |
| 906 | + | Transport: diagnostics.Transport, | |
| 907 | + | Continuation: diagnostics.Continuation, | |
| 908 | + | }) | |
| 909 | + | } | |
| 910 | + | ||
| 869 | 911 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | |
| 870 | 912 | s.sink(EvRequestStarted{ | |
| 871 | 913 | Provider: s.provider, | |
| @@ -958,13 +1000,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 958 | 1000 | return provider.StopError, provider.Message{}, err | |
| 959 | 1001 | } | |
| 960 | 1002 | system, tools := a.PromptConfig() | |
| 961 | - | systemContext := a.providerTimeContext().systemText() | |
| 962 | - | if contextText := boundedTurnContext(turnContext); contextText != "" { | |
| 963 | - | if system != "" { | |
| 964 | - | system += "\n\n" | |
| 965 | - | } | |
| 966 | - | system += "[Extension context for this turn]\n" + contextText | |
| 967 | - | } | |
| 968 | 1003 | // Repair pairs before projecting the copied provider-input view. The | |
| 969 | 1004 | // repair can add stub results for aborted calls; those results must also | |
| 970 | 1005 | // remain in the request so every tool call still has a matching result. | |
| @@ -976,9 +1011,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 976 | 1011 | model: a.Model, | |
| 977 | 1012 | } | |
| 978 | 1013 | req := provider.Request{ | |
| 979 | - | Model: a.Model, | |
| 980 | - | System: system, | |
| 981 | - | SystemContext: systemContext, | |
| 1014 | + | Model: a.Model, | |
| 1015 | + | System: system, | |
| 982 | 1016 | // Repair any dangling tool_use blocks before sending. A turn | |
| 983 | 1017 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 984 | 1018 | // dev server, etc.) can leave an assistant tool_use with no | |
| @@ -164,12 +164,15 @@ type Agent struct { | |||||
| 164 | // boundaries: before the next model call after a tool batch, or | 164 | // boundaries: before the next model call after a tool batch, or | ||
| 165 | // after a text-only assistant turn finishes. It never interrupts | 165 | // after a text-only assistant turn finishes. It never interrupts | ||
| 166 | // a running tool or cancels an in-flight provider request. | 166 | // a running tool or cancels an in-flight provider request. | ||
| 167 | - | queued []queuedMessage | 167 | + | queued []queuedMessage |
| 168 | - | timeContext agentTimeContext | 168 | + | timeContext agentTimeContext |
| 169 | - | // sessionID is assigned by the host from the durable session before the | 169 | + | hasSessionTimeContext bool |
| 170 | - | // first provider request. Agents without persistence allocate one lazily at | 170 | + | // sessionID identifies this conversation thread. cacheSessionID identifies |
| 171 | - | // their first accepted turn and retain it for the process lifetime. | 171 | + | // the root prompt-cache affinity and may be shared by resident child |
| 172 | + | // threads. Hosts bind both before the first provider request; unbound | |||
| 173 | + | // agents allocate one shared ephemeral value for the process lifetime. | |||
| 172 | sessionID string | 174 | sessionID string | ||
| 175 | + | cacheSessionID string | |||
| 173 | providerRequestStarted bool | 176 | providerRequestStarted bool | ||
| 174 | } | 177 | } | ||
| 175 | 178 | ||||
| @@ -188,37 +191,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age | |||||
| 188 | } | 191 | } | ||
| 189 | } | 192 | } | ||
| 190 | 193 | ||||
| 191 | - | // BindSessionID assigns the stable logical session identity before the first | 194 | + | // BindSessionID binds a root conversation's durable session as both its |
| 192 | - | // provider request. A durable host session must bind its SessionMeta.ID here; | 195 | + | // thread identity and prompt-cache affinity. Resident children use |
| 193 | - | // no-session hosts may leave it unset and the agent will allocate one lazily. | 196 | + | // BindRequestIdentity to retain their own thread while sharing their root's |
| 197 | + | // cache affinity. | |||
| 194 | func (a *Agent) BindSessionID(id string) error { | 198 | func (a *Agent) BindSessionID(id string) error { | ||
| 195 | - | id = strings.TrimSpace(id) | 199 | + | return a.BindRequestIdentity(id, id) |
| 196 | - | if id == "" { | 200 | + | } |
| 197 | - | return errors.New("session ID is empty") | 201 | + | |
| 202 | + | // BindRequestIdentity assigns the stable root cache and thread identities | |||
| 203 | + | // before the first provider request. | |||
| 204 | + | func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error { | |||
| 205 | + | cacheSessionID = strings.TrimSpace(cacheSessionID) | |||
| 206 | + | threadID = strings.TrimSpace(threadID) | |||
| 207 | + | if cacheSessionID == "" || threadID == "" { | |||
| 208 | + | return errors.New("cache session ID and thread ID are required") | |||
| 198 | } | 209 | } | ||
| 199 | a.mu.Lock() | 210 | a.mu.Lock() | ||
| 200 | defer a.mu.Unlock() | 211 | defer a.mu.Unlock() | ||
| 201 | if a.providerRequestStarted { | 212 | if a.providerRequestStarted { | ||
| 202 | - | return errors.New("cannot change session ID after the first provider request") | 213 | + | return errors.New("cannot change request identity after the first provider request") |
| 203 | } | 214 | } | ||
| 204 | - | a.sessionID = id | 215 | + | a.cacheSessionID = cacheSessionID |
| 216 | + | a.sessionID = threadID | |||
| 205 | return nil | 217 | return nil | ||
| 206 | } | 218 | } | ||
| 207 | 219 | ||||
| 208 | - | // SessionID returns the stable logical session identity, allocating an | 220 | + | // SessionID returns the stable conversation-thread identity, allocating an |
| 209 | // ephemeral identity when persistence is disabled and the host did not bind | 221 | // ephemeral identity when persistence is disabled and the host did not bind | ||
| 210 | // one during runtime assembly. | 222 | // one during runtime assembly. | ||
| 211 | func (a *Agent) SessionID() string { | 223 | func (a *Agent) SessionID() string { | ||
| 212 | a.mu.Lock() | 224 | a.mu.Lock() | ||
| 213 | defer a.mu.Unlock() | 225 | defer a.mu.Unlock() | ||
| 226 | + | a.ensureRequestIdentityLocked() | |||
| 227 | + | return a.sessionID | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // CacheSessionID returns the stable root cache-affinity identity. | |||
| 231 | + | func (a *Agent) CacheSessionID() string { | |||
| 232 | + | a.mu.Lock() | |||
| 233 | + | defer a.mu.Unlock() | |||
| 234 | + | a.ensureRequestIdentityLocked() | |||
| 235 | + | return a.cacheSessionID | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (a *Agent) ensureRequestIdentityLocked() { | |||
| 214 | if a.sessionID == "" { | 239 | if a.sessionID == "" { | ||
| 215 | a.sessionID = uuid.NewString() | 240 | a.sessionID = uuid.NewString() | ||
| 216 | } | 241 | } | ||
| 217 | - | return a.sessionID | 242 | + | if a.cacheSessionID == "" { |
| 243 | + | a.cacheSessionID = a.sessionID | |||
| 244 | + | } | |||
| 218 | } | 245 | } | ||
| 219 | 246 | ||||
| 220 | func (a *Agent) beginTurn() provider.RequestContext { | 247 | func (a *Agent) beginTurn() provider.RequestContext { | ||
| 221 | - | return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()} | 248 | + | a.mu.Lock() |
| 249 | + | a.ensureRequestIdentityLocked() | |||
| 250 | + | context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()} | |||
| 251 | + | a.mu.Unlock() | |||
| 252 | + | return context | |||
| 222 | } | 253 | } | ||
| 223 | 254 | ||||
| 224 | func (a *Agent) beginProviderRequest() { | 255 | func (a *Agent) beginProviderRequest() { | ||
| @@ -593,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 593 | } | 624 | } | ||
| 594 | } | 625 | } | ||
| 595 | 626 | ||||
| 627 | + | a.appendDynamicContext(turnContext) | |||
| 628 | + | ||||
| 596 | var ( | 629 | var ( | ||
| 597 | stop provider.StopReason | 630 | stop provider.StopReason | ||
| 598 | assistantMsg provider.Message | 631 | assistantMsg provider.Message | ||
| @@ -866,6 +899,15 @@ type requestLifecycleSink struct { | |||||
| 866 | lastReason RetryReason | 899 | lastReason RetryReason | ||
| 867 | } | 900 | } | ||
| 868 | 901 | ||||
| 902 | + | func (s *requestLifecycleSink) CacheDiagnostics(diagnostics provider.CacheDiagnostics) { | |||
| 903 | + | s.sink(EvCacheDiagnostics{ | |||
| 904 | + | Eligible: diagnostics.Eligible, | |||
| 905 | + | Mode: diagnostics.Mode, | |||
| 906 | + | Transport: diagnostics.Transport, | |||
| 907 | + | Continuation: diagnostics.Continuation, | |||
| 908 | + | }) | |||
| 909 | + | } | |||
| 910 | + | ||||
| 869 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | 911 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | ||
| 870 | s.sink(EvRequestStarted{ | 912 | s.sink(EvRequestStarted{ | ||
| 871 | Provider: s.provider, | 913 | Provider: s.provider, | ||
| @@ -958,13 +1000,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 958 | return provider.StopError, provider.Message{}, err | 1000 | return provider.StopError, provider.Message{}, err | ||
| 959 | } | 1001 | } | ||
| 960 | system, tools := a.PromptConfig() | 1002 | system, tools := a.PromptConfig() | ||
| 961 | - | systemContext := a.providerTimeContext().systemText() | |||
| 962 | - | if contextText := boundedTurnContext(turnContext); contextText != "" { | |||
| 963 | - | if system != "" { | |||
| 964 | - | system += "\n\n" | |||
| 965 | - | } | |||
| 966 | - | system += "[Extension context for this turn]\n" + contextText | |||
| 967 | - | } | |||
| 968 | // Repair pairs before projecting the copied provider-input view. The | 1003 | // Repair pairs before projecting the copied provider-input view. The | ||
| 969 | // repair can add stub results for aborted calls; those results must also | 1004 | // repair can add stub results for aborted calls; those results must also | ||
| 970 | // remain in the request so every tool call still has a matching result. | 1005 | // remain in the request so every tool call still has a matching result. | ||
| @@ -976,9 +1011,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 976 | model: a.Model, | 1011 | model: a.Model, | ||
| 977 | } | 1012 | } | ||
| 978 | req := provider.Request{ | 1013 | req := provider.Request{ | ||
| 979 | - | Model: a.Model, | 1014 | + | Model: a.Model, |
| 980 | - | System: system, | 1015 | + | System: system, |
| 981 | - | SystemContext: systemContext, | |||
| 982 | // Repair any dangling tool_use blocks before sending. A turn | 1016 | // Repair any dangling tool_use blocks before sending. A turn | ||
| 983 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | 1017 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | ||
| 984 | // dev server, etc.) can leave an assistant tool_use with no | 1018 | // dev server, etc.) can leave an assistant tool_use with no | ||
M packages/core/agent_identity_test.go +9 -9
| @@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request { | |||
| 49 | 49 | return requests | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |
| 52 | + | func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) { | |
| 53 | 53 | client := &identityRecordingClient{failOnce: true} | |
| 54 | 54 | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 55 | 55 | agent.MaxRetries = 1 | |
| 56 | 56 | agent.RetryBaseDelay = time.Millisecond | |
| 57 | - | if err := agent.BindSessionID("session-1"); err != nil { | |
| 58 | - | t.Fatalf("BindSessionID: %v", err) | |
| 57 | + | if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil { | |
| 58 | + | t.Fatalf("BindRequestIdentity: %v", err) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil { | |
| @@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||
| 70 | 70 | t.Fatalf("request count = %d, want 3", len(requests)) | |
| 71 | 71 | } | |
| 72 | 72 | for i, request := range requests { | |
| 73 | - | if request.Context.SessionID != "session-1" { | |
| 74 | - | t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID) | |
| 73 | + | if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" { | |
| 74 | + | t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context) | |
| 75 | 75 | } | |
| 76 | 76 | if request.Context.TurnID == "" { | |
| 77 | 77 | t.Fatalf("request %d has empty turn ID", i) | |
| @@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||
| 83 | 83 | if requests[1].Context.TurnID == requests[2].Context.TurnID { | |
| 84 | 84 | t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID) | |
| 85 | 85 | } | |
| 86 | - | if err := agent.BindSessionID("other-session"); err == nil { | |
| 87 | - | t.Fatal("BindSessionID succeeded after the first provider request") | |
| 86 | + | if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil { | |
| 87 | + | t.Fatal("BindRequestIdentity succeeded after the first provider request") | |
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | ||
| @@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) { | |||
| 102 | 102 | if len(requests) != 2 { | |
| 103 | 103 | t.Fatalf("request count = %d, want 2", len(requests)) | |
| 104 | 104 | } | |
| 105 | - | if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID { | |
| 106 | - | t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID) | |
| 105 | + | if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID { | |
| 106 | + | t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context) | |
| 107 | 107 | } | |
| 108 | 108 | } | |
| @@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request { | |||||
| 49 | return requests | 49 | return requests | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | 52 | + | func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) { |
| 53 | client := &identityRecordingClient{failOnce: true} | 53 | client := &identityRecordingClient{failOnce: true} | ||
| 54 | agent := NewAgent(client, "test-model", "system", Registry{}) | 54 | agent := NewAgent(client, "test-model", "system", Registry{}) | ||
| 55 | agent.MaxRetries = 1 | 55 | agent.MaxRetries = 1 | ||
| 56 | agent.RetryBaseDelay = time.Millisecond | 56 | agent.RetryBaseDelay = time.Millisecond | ||
| 57 | - | if err := agent.BindSessionID("session-1"); err != nil { | 57 | + | if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil { |
| 58 | - | t.Fatalf("BindSessionID: %v", err) | 58 | + | t.Fatalf("BindRequestIdentity: %v", err) |
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil { | 61 | if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil { | ||
| @@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||||
| 70 | t.Fatalf("request count = %d, want 3", len(requests)) | 70 | t.Fatalf("request count = %d, want 3", len(requests)) | ||
| 71 | } | 71 | } | ||
| 72 | for i, request := range requests { | 72 | for i, request := range requests { | ||
| 73 | - | if request.Context.SessionID != "session-1" { | 73 | + | if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" { |
| 74 | - | t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID) | 74 | + | t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context) |
| 75 | } | 75 | } | ||
| 76 | if request.Context.TurnID == "" { | 76 | if request.Context.TurnID == "" { | ||
| 77 | t.Fatalf("request %d has empty turn ID", i) | 77 | t.Fatalf("request %d has empty turn ID", i) | ||
| @@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||||
| 83 | if requests[1].Context.TurnID == requests[2].Context.TurnID { | 83 | if requests[1].Context.TurnID == requests[2].Context.TurnID { | ||
| 84 | t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID) | 84 | t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID) | ||
| 85 | } | 85 | } | ||
| 86 | - | if err := agent.BindSessionID("other-session"); err == nil { | 86 | + | if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil { |
| 87 | - | t.Fatal("BindSessionID succeeded after the first provider request") | 87 | + | t.Fatal("BindRequestIdentity succeeded after the first provider request") |
| 88 | } | 88 | } | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| @@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) { | |||||
| 102 | if len(requests) != 2 { | 102 | if len(requests) != 2 { | ||
| 103 | t.Fatalf("request count = %d, want 2", len(requests)) | 103 | t.Fatalf("request count = %d, want 2", len(requests)) | ||
| 104 | } | 104 | } | ||
| 105 | - | if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID { | 105 | + | if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID { |
| 106 | - | t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID) | 106 | + | t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context) |
| 107 | } | 107 | } | ||
| 108 | } | 108 | } | ||
M packages/core/agent_retry_test.go +7 -9
| @@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha | |||
| 489 | 489 | return out, nil | |
| 490 | 490 | } | |
| 491 | 491 | ||
| 492 | - | func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |
| 492 | + | func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) { | |
| 493 | 493 | client := &captureClient{} | |
| 494 | 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | |
| 495 | 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |
| @@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |||
| 498 | 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 499 | 499 | t.Fatalf("Prompt returned %v", err) | |
| 500 | 500 | } | |
| 501 | - | if !strings.Contains(client.lastReq.System, "current phase: parse files") { | |
| 502 | - | t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System) | |
| 501 | + | if strings.Contains(client.lastReq.System, "current phase: parse files") { | |
| 502 | + | t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System) | |
| 503 | 503 | } | |
| 504 | - | if len(a.Messages()) != 2 { | |
| 505 | - | t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages())) | |
| 504 | + | if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") { | |
| 505 | + | t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages) | |
| 506 | 506 | } | |
| 507 | - | for _, msg := range a.Messages() { | |
| 508 | - | if strings.Contains(extractText(msg), "current phase: parse files") { | |
| 509 | - | t.Fatal("hidden turn context leaked into transcript") | |
| 510 | - | } | |
| 507 | + | if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper { | |
| 508 | + | t.Fatalf("persisted transcript = %#v, want developer context", a.Messages()) | |
| 511 | 509 | } | |
| 512 | 510 | } | |
| 513 | 511 | ||
| @@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha | |||||
| 489 | return out, nil | 489 | return out, nil | ||
| 490 | } | 490 | } | ||
| 491 | 491 | ||||
| 492 | - | func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | 492 | + | func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) { |
| 493 | client := &captureClient{} | 493 | client := &captureClient{} | ||
| 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | ||
| 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | ||
| @@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |||||
| 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | ||
| 499 | t.Fatalf("Prompt returned %v", err) | 499 | t.Fatalf("Prompt returned %v", err) | ||
| 500 | } | 500 | } | ||
| 501 | - | if !strings.Contains(client.lastReq.System, "current phase: parse files") { | 501 | + | if strings.Contains(client.lastReq.System, "current phase: parse files") { |
| 502 | - | t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System) | 502 | + | t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System) |
| 503 | } | 503 | } | ||
| 504 | - | if len(a.Messages()) != 2 { | 504 | + | if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") { |
| 505 | - | t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages())) | 505 | + | t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages) |
| 506 | } | 506 | } | ||
| 507 | - | for _, msg := range a.Messages() { | 507 | + | if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper { |
| 508 | - | if strings.Contains(extractText(msg), "current phase: parse files") { | 508 | + | t.Fatalf("persisted transcript = %#v, want developer context", a.Messages()) |
| 509 | - | t.Fatal("hidden turn context leaked into transcript") | |||
| 510 | - | } | |||
| 511 | } | 509 | } | ||
| 512 | } | 510 | } | ||
| 513 | 511 | ||||
M packages/core/compact.go +12 -8
| @@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 37 | 37 | msgs := append([]provider.Message(nil), a.messages...) | |
| 38 | 38 | a.mu.Unlock() | |
| 39 | 39 | ||
| 40 | + | latestContext, msgs := latestInternalContext(msgs) | |
| 40 | 41 | if len(msgs) == 0 { | |
| 41 | 42 | return "", fmt.Errorf("nothing to compact") | |
| 42 | 43 | } | |
| @@ -62,14 +63,13 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 62 | 63 | ||
| 63 | 64 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | |
| 64 | 65 | ||
| 65 | - | systemContext := a.providerTimeContext().systemText() | |
| 66 | 66 | req := provider.Request{ | |
| 67 | - | Model: a.Model, | |
| 68 | - | System: summarizationSystem, | |
| 69 | - | SystemContext: systemContext, | |
| 70 | - | MaxTokens: 4096, | |
| 71 | - | Temperature: a.Temperature, | |
| 72 | - | FastMode: fastMode, | |
| 67 | + | Context: a.beginTurn(), | |
| 68 | + | Model: a.Model, | |
| 69 | + | System: summarizationSystem, | |
| 70 | + | MaxTokens: 4096, | |
| 71 | + | Temperature: a.Temperature, | |
| 72 | + | FastMode: fastMode, | |
| 73 | 73 | Messages: []provider.Message{ | |
| 74 | 74 | { | |
| 75 | 75 | Role: provider.RoleUser, | |
| @@ -100,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 100 | 100 | }) | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | + | a.beginProviderRequest() | |
| 103 | 104 | stream, err := a.Client.Stream(ctx, req) | |
| 104 | 105 | if err != nil { | |
| 105 | 106 | return "", err | |
| @@ -171,7 +172,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 171 | 172 | // a tool_use ID that doesn't exist. | |
| 172 | 173 | tail = repairOrphanedToolResults(tail) | |
| 173 | 174 | ||
| 174 | - | next := make([]provider.Message, 0, 1+len(tail)) | |
| 175 | + | next := make([]provider.Message, 0, 2+len(tail)) | |
| 176 | + | if latestContext != nil { | |
| 177 | + | next = append(next, *latestContext) | |
| 178 | + | } | |
| 175 | 179 | next = append(next, synthetic) | |
| 176 | 180 | next = append(next, tail...) | |
| 177 | 181 | ||
| @@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 37 | msgs := append([]provider.Message(nil), a.messages...) | 37 | msgs := append([]provider.Message(nil), a.messages...) | ||
| 38 | a.mu.Unlock() | 38 | a.mu.Unlock() | ||
| 39 | 39 | ||||
| 40 | + | latestContext, msgs := latestInternalContext(msgs) | |||
| 40 | if len(msgs) == 0 { | 41 | if len(msgs) == 0 { | ||
| 41 | return "", fmt.Errorf("nothing to compact") | 42 | return "", fmt.Errorf("nothing to compact") | ||
| 42 | } | 43 | } | ||
| @@ -62,14 +63,13 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 62 | 63 | ||||
| 63 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | 64 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | ||
| 64 | 65 | ||||
| 65 | - | systemContext := a.providerTimeContext().systemText() | |||
| 66 | req := provider.Request{ | 66 | req := provider.Request{ | ||
| 67 | - | Model: a.Model, | 67 | + | Context: a.beginTurn(), |
| 68 | - | System: summarizationSystem, | 68 | + | Model: a.Model, |
| 69 | - | SystemContext: systemContext, | 69 | + | System: summarizationSystem, |
| 70 | - | MaxTokens: 4096, | 70 | + | MaxTokens: 4096, |
| 71 | - | Temperature: a.Temperature, | 71 | + | Temperature: a.Temperature, |
| 72 | - | FastMode: fastMode, | 72 | + | FastMode: fastMode, |
| 73 | Messages: []provider.Message{ | 73 | Messages: []provider.Message{ | ||
| 74 | { | 74 | { | ||
| 75 | Role: provider.RoleUser, | 75 | Role: provider.RoleUser, | ||
| @@ -100,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 100 | }) | 100 | }) | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | + | a.beginProviderRequest() | |||
| 103 | stream, err := a.Client.Stream(ctx, req) | 104 | stream, err := a.Client.Stream(ctx, req) | ||
| 104 | if err != nil { | 105 | if err != nil { | ||
| 105 | return "", err | 106 | return "", err | ||
| @@ -171,7 +172,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 171 | // a tool_use ID that doesn't exist. | 172 | // a tool_use ID that doesn't exist. | ||
| 172 | tail = repairOrphanedToolResults(tail) | 173 | tail = repairOrphanedToolResults(tail) | ||
| 173 | 174 | ||||
| 174 | - | next := make([]provider.Message, 0, 1+len(tail)) | 175 | + | next := make([]provider.Message, 0, 2+len(tail)) |
| 176 | + | if latestContext != nil { | |||
| 177 | + | next = append(next, *latestContext) | |||
| 178 | + | } | |||
| 175 | next = append(next, synthetic) | 179 | next = append(next, synthetic) | ||
| 176 | next = append(next, tail...) | 180 | next = append(next, tail...) | ||
| 177 | 181 | ||||
M packages/core/compact_lifecycle_test.go +36 -0
| @@ -53,6 +53,42 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) | |||
| 53 | 53 | return out, nil | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | + | func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) { | |
| 57 | + | client := &compactLifecycleClient{} | |
| 58 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| 59 | + | agent.SetMessages([]provider.Message{ | |
| 60 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |
| 61 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |
| 62 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |
| 63 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |
| 64 | + | }) | |
| 65 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | messages := agent.Messages() | |
| 69 | + | if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" { | |
| 70 | + | t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestCompactStartsProviderRequestIdentity(t *testing.T) { | |
| 75 | + | client := &compactLifecycleClient{} | |
| 76 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| 77 | + | agent.SetMessages([]provider.Message{{ | |
| 78 | + | Role: provider.RoleUser, | |
| 79 | + | Content: []provider.Content{provider.TextBlock{Text: "source"}}, | |
| 80 | + | }}) | |
| 81 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if client.req.Context.CacheSessionID == "" || client.req.Context.ThreadID == "" || client.req.Context.TurnID == "" { | |
| 85 | + | t.Fatalf("compaction request context = %#v, want all identities", client.req.Context) | |
| 86 | + | } | |
| 87 | + | if err := agent.BindRequestIdentity("other-cache", "other-thread"); err == nil { | |
| 88 | + | t.Fatal("BindRequestIdentity succeeded after compaction provider request") | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 56 | 92 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | |
| 57 | 93 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | |
| 58 | 94 | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| @@ -53,6 +53,42 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) | |||||
| 53 | return out, nil | 53 | return out, nil | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | + | func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) { | |||
| 57 | + | client := &compactLifecycleClient{} | |||
| 58 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |||
| 59 | + | agent.SetMessages([]provider.Message{ | |||
| 60 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |||
| 61 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |||
| 62 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |||
| 63 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |||
| 64 | + | }) | |||
| 65 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | messages := agent.Messages() | |||
| 69 | + | if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" { | |||
| 70 | + | t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestCompactStartsProviderRequestIdentity(t *testing.T) { | |||
| 75 | + | client := &compactLifecycleClient{} | |||
| 76 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |||
| 77 | + | agent.SetMessages([]provider.Message{{ | |||
| 78 | + | Role: provider.RoleUser, | |||
| 79 | + | Content: []provider.Content{provider.TextBlock{Text: "source"}}, | |||
| 80 | + | }}) | |||
| 81 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | if client.req.Context.CacheSessionID == "" || client.req.Context.ThreadID == "" || client.req.Context.TurnID == "" { | |||
| 85 | + | t.Fatalf("compaction request context = %#v, want all identities", client.req.Context) | |||
| 86 | + | } | |||
| 87 | + | if err := agent.BindRequestIdentity("other-cache", "other-thread"); err == nil { | |||
| 88 | + | t.Fatal("BindRequestIdentity succeeded after compaction provider request") | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 56 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | 92 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | ||
| 57 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | 93 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | ||
| 58 | agent := NewAgent(client, "compact-model", "system", Registry{}) | 94 | agent := NewAgent(client, "compact-model", "system", Registry{}) | ||
M packages/core/context_budget_test.go +1 -1
| @@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) { | |||
| 46 | 46 | if first != second { | |
| 47 | 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | |
| 48 | 48 | } | |
| 49 | - | for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | |
| 49 | + | for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | |
| 50 | 50 | if !strings.Contains(first, want) { | |
| 51 | 51 | t.Fatalf("time context %q missing %q", first, want) | |
| 52 | 52 | } | |
| @@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) { | |||||
| 46 | if first != second { | 46 | if first != second { | ||
| 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | ||
| 48 | } | 48 | } | ||
| 49 | - | for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | 49 | + | for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} { |
| 50 | if !strings.Contains(first, want) { | 50 | if !strings.Contains(first, want) { | ||
| 51 | t.Fatalf("time context %q missing %q", first, want) | 51 | t.Fatalf("time context %q missing %q", first, want) | ||
| 52 | } | 52 | } | ||
M packages/core/cost.go +1 -1
| @@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage { | |||
| 31 | 31 | // Seed replaces the running total with usage restored from a session. | |
| 32 | 32 | func (c *CostTracker) Seed(u provider.Usage) { | |
| 33 | 33 | c.Total = u | |
| 34 | - | c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 0 | |
| 34 | + | c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0 | |
| 35 | 35 | } | |
| @@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage { | |||||
| 31 | // Seed replaces the running total with usage restored from a session. | 31 | // Seed replaces the running total with usage restored from a session. | ||
| 32 | func (c *CostTracker) Seed(u provider.Usage) { | 32 | func (c *CostTracker) Seed(u provider.Usage) { | ||
| 33 | c.Total = u | 33 | c.Total = u | ||
| 34 | - | c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 0 | 34 | + | c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0 |
| 35 | } | 35 | } | ||
A packages/core/dynamic_context.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const internalContextMarker = "internal_context" | |
| 11 | + | ||
| 12 | + | // appendDynamicContext persists a provider-visible, host-authored context item | |
| 13 | + | // only when its canonical value changes. It is deliberately separate from the | |
| 14 | + | // stable top-level instructions so changing time or extension state cannot | |
| 15 | + | // rewrite the instruction cache prefix. | |
| 16 | + | func (a *Agent) appendDynamicContext(turnContext string) { | |
| 17 | + | parts := make([]string, 0, 2) | |
| 18 | + | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | |
| 19 | + | parts = append(parts, timeContext) | |
| 20 | + | } | |
| 21 | + | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | |
| 22 | + | parts = append(parts, "[Extension context]\n"+turnContext) | |
| 23 | + | } | |
| 24 | + | text := strings.Join(parts, "\n\n") | |
| 25 | + | if text == "" { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | message := provider.Message{ | |
| 30 | + | Role: provider.RoleDeveloper, | |
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 32 | + | Time: time.Now(), | |
| 33 | + | Meta: map[string]string{internalContextMarker: "true"}, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | a.mu.Lock() | |
| 37 | + | for i := len(a.messages) - 1; i >= 0; i-- { | |
| 38 | + | if !isInternalContextMessage(a.messages[i]) { | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if internalContextText(a.messages[i]) == text { | |
| 42 | + | a.mu.Unlock() | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | break | |
| 46 | + | } | |
| 47 | + | // The first dynamic item must precede the accepted user task. Persisting it | |
| 48 | + | // as an append would reverse that order on session reload, so report the | |
| 49 | + | // resulting transcript replacement instead. Later snapshots extend the | |
| 50 | + | // history at the next model boundary and remain ordinary appends. | |
| 51 | + | first := !hasInternalContextMessage(a.messages) | |
| 52 | + | insertAt := len(a.messages) | |
| 53 | + | if first { | |
| 54 | + | insertAt = 0 | |
| 55 | + | } | |
| 56 | + | a.messages = append(a.messages, provider.Message{}) | |
| 57 | + | copy(a.messages[insertAt+1:], a.messages[insertAt:]) | |
| 58 | + | a.messages[insertAt] = message | |
| 59 | + | a.rev++ | |
| 60 | + | onCompacted := a.OnTranscriptCompacted | |
| 61 | + | persisted := append([]provider.Message(nil), a.messages...) | |
| 62 | + | a.mu.Unlock() | |
| 63 | + | if first && onCompacted != nil { | |
| 64 | + | onCompacted(persisted) | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | a.fireMessageAppended(message) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func hasInternalContextMessage(messages []provider.Message) bool { | |
| 71 | + | for _, message := range messages { | |
| 72 | + | if isInternalContextMessage(message) { | |
| 73 | + | return true | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func isInternalContextMessage(message provider.Message) bool { | |
| 80 | + | return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true" | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) { | |
| 84 | + | filtered := make([]provider.Message, 0, len(messages)) | |
| 85 | + | var latest *provider.Message | |
| 86 | + | for _, message := range messages { | |
| 87 | + | if isInternalContextMessage(message) { | |
| 88 | + | copy := message | |
| 89 | + | latest = © | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | filtered = append(filtered, message) | |
| 93 | + | } | |
| 94 | + | return latest, filtered | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func internalContextText(message provider.Message) string { | |
| 98 | + | if len(message.Content) != 1 { | |
| 99 | + | return "" | |
| 100 | + | } | |
| 101 | + | text, _ := message.Content[0].(provider.TextBlock) | |
| 102 | + | return text.Text | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const internalContextMarker = "internal_context" | |||
| 11 | + | ||||
| 12 | + | // appendDynamicContext persists a provider-visible, host-authored context item | |||
| 13 | + | // only when its canonical value changes. It is deliberately separate from the | |||
| 14 | + | // stable top-level instructions so changing time or extension state cannot | |||
| 15 | + | // rewrite the instruction cache prefix. | |||
| 16 | + | func (a *Agent) appendDynamicContext(turnContext string) { | |||
| 17 | + | parts := make([]string, 0, 2) | |||
| 18 | + | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | |||
| 19 | + | parts = append(parts, timeContext) | |||
| 20 | + | } | |||
| 21 | + | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | |||
| 22 | + | parts = append(parts, "[Extension context]\n"+turnContext) | |||
| 23 | + | } | |||
| 24 | + | text := strings.Join(parts, "\n\n") | |||
| 25 | + | if text == "" { | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | message := provider.Message{ | |||
| 30 | + | Role: provider.RoleDeveloper, | |||
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 32 | + | Time: time.Now(), | |||
| 33 | + | Meta: map[string]string{internalContextMarker: "true"}, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | a.mu.Lock() | |||
| 37 | + | for i := len(a.messages) - 1; i >= 0; i-- { | |||
| 38 | + | if !isInternalContextMessage(a.messages[i]) { | |||
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | if internalContextText(a.messages[i]) == text { | |||
| 42 | + | a.mu.Unlock() | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | break | |||
| 46 | + | } | |||
| 47 | + | // The first dynamic item must precede the accepted user task. Persisting it | |||
| 48 | + | // as an append would reverse that order on session reload, so report the | |||
| 49 | + | // resulting transcript replacement instead. Later snapshots extend the | |||
| 50 | + | // history at the next model boundary and remain ordinary appends. | |||
| 51 | + | first := !hasInternalContextMessage(a.messages) | |||
| 52 | + | insertAt := len(a.messages) | |||
| 53 | + | if first { | |||
| 54 | + | insertAt = 0 | |||
| 55 | + | } | |||
| 56 | + | a.messages = append(a.messages, provider.Message{}) | |||
| 57 | + | copy(a.messages[insertAt+1:], a.messages[insertAt:]) | |||
| 58 | + | a.messages[insertAt] = message | |||
| 59 | + | a.rev++ | |||
| 60 | + | onCompacted := a.OnTranscriptCompacted | |||
| 61 | + | persisted := append([]provider.Message(nil), a.messages...) | |||
| 62 | + | a.mu.Unlock() | |||
| 63 | + | if first && onCompacted != nil { | |||
| 64 | + | onCompacted(persisted) | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | a.fireMessageAppended(message) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func hasInternalContextMessage(messages []provider.Message) bool { | |||
| 71 | + | for _, message := range messages { | |||
| 72 | + | if isInternalContextMessage(message) { | |||
| 73 | + | return true | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func isInternalContextMessage(message provider.Message) bool { | |||
| 80 | + | return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true" | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) { | |||
| 84 | + | filtered := make([]provider.Message, 0, len(messages)) | |||
| 85 | + | var latest *provider.Message | |||
| 86 | + | for _, message := range messages { | |||
| 87 | + | if isInternalContextMessage(message) { | |||
| 88 | + | copy := message | |||
| 89 | + | latest = © | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | filtered = append(filtered, message) | |||
| 93 | + | } | |||
| 94 | + | return latest, filtered | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func internalContextText(message provider.Message) string { | |||
| 98 | + | if len(message.Content) != 1 { | |||
| 99 | + | return "" | |||
| 100 | + | } | |||
| 101 | + | text, _ := message.Content[0].(provider.TextBlock) | |||
| 102 | + | return text.Text | |||
| 103 | + | } | |||
A packages/core/dynamic_context_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) { | |
| 11 | + | client := &compactLifecycleClient{} | |
| 12 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 13 | + | session, err := NewSession(t.TempDir(), "/workspace", "test", "test-model", "test") | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | agent.OnMessageAppended = func(message provider.Message) { | |
| 18 | + | if err := session.AppendMessage(message); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | agent.OnTranscriptCompacted = func(messages []provider.Message) { | |
| 23 | + | if err := session.AppendCompaction(messages); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |
| 28 | + | return true, "", "accepted extension context" | |
| 29 | + | } | |
| 30 | + | if err := agent.Prompt(context.Background(), "accepted user message", nil, nil); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | path := session.Path | |
| 34 | + | if err := session.Close(); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | opened, messages, err := OpenSession(path) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | defer opened.Close() | |
| 43 | + | if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser { | |
| 44 | + | t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages) | |
| 45 | + | } | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) { | |||
| 11 | + | client := &compactLifecycleClient{} | |||
| 12 | + | agent := NewAgent(client, "test-model", "system", Registry{}) | |||
| 13 | + | session, err := NewSession(t.TempDir(), "/workspace", "test", "test-model", "test") | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | agent.OnMessageAppended = func(message provider.Message) { | |||
| 18 | + | if err := session.AppendMessage(message); err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | agent.OnTranscriptCompacted = func(messages []provider.Message) { | |||
| 23 | + | if err := session.AppendCompaction(messages); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |||
| 28 | + | return true, "", "accepted extension context" | |||
| 29 | + | } | |||
| 30 | + | if err := agent.Prompt(context.Background(), "accepted user message", nil, nil); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | path := session.Path | |||
| 34 | + | if err := session.Close(); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | opened, messages, err := OpenSession(path) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | defer opened.Close() | |||
| 43 | + | if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser { | |||
| 44 | + | t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages) | |||
| 45 | + | } | |||
| 46 | + | } | |||
M packages/core/events.go +11 -0
| @@ -86,6 +86,17 @@ type EvRequestStarted struct { | |||
| 86 | 86 | ||
| 87 | 87 | func (EvRequestStarted) Type() string { return "request_started" } | |
| 88 | 88 | ||
| 89 | + | // EvCacheDiagnostics reports sanitized provider cache-routing state. It never | |
| 90 | + | // includes prompt text, durable IDs, request bodies, or credentials. | |
| 91 | + | type EvCacheDiagnostics struct { | |
| 92 | + | Eligible bool | |
| 93 | + | Mode string | |
| 94 | + | Transport string | |
| 95 | + | Continuation string | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (EvCacheDiagnostics) Type() string { return "cache_diagnostics" } | |
| 99 | + | ||
| 89 | 100 | // EvRetryScheduled reports a retry delay before its upcoming attempt. | |
| 90 | 101 | type EvRetryScheduled struct { | |
| 91 | 102 | Scope RetryScope | |
| @@ -86,6 +86,17 @@ type EvRequestStarted struct { | |||||
| 86 | 86 | ||||
| 87 | func (EvRequestStarted) Type() string { return "request_started" } | 87 | func (EvRequestStarted) Type() string { return "request_started" } | ||
| 88 | 88 | ||||
| 89 | + | // EvCacheDiagnostics reports sanitized provider cache-routing state. It never | |||
| 90 | + | // includes prompt text, durable IDs, request bodies, or credentials. | |||
| 91 | + | type EvCacheDiagnostics struct { | |||
| 92 | + | Eligible bool | |||
| 93 | + | Mode string | |||
| 94 | + | Transport string | |||
| 95 | + | Continuation string | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (EvCacheDiagnostics) Type() string { return "cache_diagnostics" } | |||
| 99 | + | ||||
| 89 | // EvRetryScheduled reports a retry delay before its upcoming attempt. | 100 | // EvRetryScheduled reports a retry delay before its upcoming attempt. | ||
| 90 | type EvRetryScheduled struct { | 101 | type EvRetryScheduled struct { | ||
| 91 | Scope RetryScope | 102 | Scope RetryScope | ||
M packages/core/session.go +3 -1
| @@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e | |||
| 367 | 367 | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | |
| 368 | 368 | lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens) | |
| 369 | 369 | lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens) | |
| 370 | + | lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens) | |
| 371 | + | lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens) | |
| 370 | 372 | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | |
| 371 | 373 | if lastTurn.CostUSD < 0 { | |
| 372 | 374 | lastTurn.CostUSD = 0 | |
| @@ -1810,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 1810 | 1812 | if err := json.Unmarshal(rawMessage, &row); err != nil { | |
| 1811 | 1813 | return provider.Message{}, err | |
| 1812 | 1814 | } | |
| 1813 | - | if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool { | |
| 1815 | + | if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool && row.Role != provider.RoleDeveloper { | |
| 1814 | 1816 | return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role) | |
| 1815 | 1817 | } | |
| 1816 | 1818 | if len(row.Content) == 0 { | |
| @@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e | |||||
| 367 | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | 367 | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | ||
| 368 | lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens) | 368 | lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens) | ||
| 369 | lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens) | 369 | lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens) | ||
| 370 | + | lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens) | |||
| 371 | + | lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens) | |||
| 370 | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | 372 | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | ||
| 371 | if lastTurn.CostUSD < 0 { | 373 | if lastTurn.CostUSD < 0 { | ||
| 372 | lastTurn.CostUSD = 0 | 374 | lastTurn.CostUSD = 0 | ||
| @@ -1810,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||||
| 1810 | if err := json.Unmarshal(rawMessage, &row); err != nil { | 1812 | if err := json.Unmarshal(rawMessage, &row); err != nil { | ||
| 1811 | return provider.Message{}, err | 1813 | return provider.Message{}, err | ||
| 1812 | } | 1814 | } | ||
| 1813 | - | if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool { | 1815 | + | if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool && row.Role != provider.RoleDeveloper { |
| 1814 | return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role) | 1816 | return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role) | ||
| 1815 | } | 1817 | } | ||
| 1816 | if len(row.Content) == 0 { | 1818 | if len(row.Content) == 0 { | ||
A packages/core/session_developer_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestOpenSessionHydratesDeveloperMessage(t *testing.T) { | |
| 10 | + | session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test") | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | if err := session.AppendMessage(provider.Message{ | |
| 15 | + | Role: provider.RoleDeveloper, | |
| 16 | + | Content: []provider.Content{provider.TextBlock{Text: "host context"}}, | |
| 17 | + | }); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | path := session.Path | |
| 21 | + | if err := session.Close(); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | _, messages, err := OpenSession(path) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatalf("OpenSession: %v", err) | |
| 27 | + | } | |
| 28 | + | if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper { | |
| 29 | + | t.Fatalf("messages = %#v, want one developer message", messages) | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/provider" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestOpenSessionHydratesDeveloperMessage(t *testing.T) { | |||
| 10 | + | session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test") | |||
| 11 | + | if err != nil { | |||
| 12 | + | t.Fatal(err) | |||
| 13 | + | } | |||
| 14 | + | if err := session.AppendMessage(provider.Message{ | |||
| 15 | + | Role: provider.RoleDeveloper, | |||
| 16 | + | Content: []provider.Content{provider.TextBlock{Text: "host context"}}, | |||
| 17 | + | }); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | path := session.Path | |||
| 21 | + | if err := session.Close(); err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | _, messages, err := OpenSession(path) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatalf("OpenSession: %v", err) | |||
| 27 | + | } | |||
| 28 | + | if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper { | |||
| 29 | + | t.Fatalf("messages = %#v, want one developer message", messages) | |||
| 30 | + | } | |||
| 31 | + | } | |||
M packages/core/session_portable.go +6 -2
| @@ -491,7 +491,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 491 | 491 | return "", err | |
| 492 | 492 | } | |
| 493 | 493 | newID := uuid.NewString() | |
| 494 | - | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8]) | |
| 494 | + | now := time.Now() | |
| 495 | + | timezone, timezoneOffset := localTimeMetadata(now) | |
| 496 | + | name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8]) | |
| 495 | 497 | outPath := filepath.Join(dir, name) | |
| 496 | 498 | // Build in a same-directory temporary file. Rename is atomic only when | |
| 497 | 499 | // the temporary and final paths share a directory; closing the file first | |
| @@ -521,8 +523,10 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 521 | 523 | CWD: cwd, | |
| 522 | 524 | Model: parent.Model, | |
| 523 | 525 | Provider: parent.Provider, | |
| 524 | - | Started: time.Now().UTC(), | |
| 526 | + | Started: now.UTC(), | |
| 525 | 527 | Version: version, | |
| 528 | + | Timezone: timezone, | |
| 529 | + | TimezoneOffset: timezoneOffset, | |
| 526 | 530 | Parent: parent.ID, | |
| 527 | 531 | ForkPoint: limit, | |
| 528 | 532 | HideFromSessions: hideFromSessions, | |
| @@ -491,7 +491,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||||
| 491 | return "", err | 491 | return "", err | ||
| 492 | } | 492 | } | ||
| 493 | newID := uuid.NewString() | 493 | newID := uuid.NewString() | ||
| 494 | - | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8]) | 494 | + | now := time.Now() |
| 495 | + | timezone, timezoneOffset := localTimeMetadata(now) | |||
| 496 | + | name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8]) | |||
| 495 | outPath := filepath.Join(dir, name) | 497 | outPath := filepath.Join(dir, name) | ||
| 496 | // Build in a same-directory temporary file. Rename is atomic only when | 498 | // Build in a same-directory temporary file. Rename is atomic only when | ||
| 497 | // the temporary and final paths share a directory; closing the file first | 499 | // the temporary and final paths share a directory; closing the file first | ||
| @@ -521,8 +523,10 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||||
| 521 | CWD: cwd, | 523 | CWD: cwd, | ||
| 522 | Model: parent.Model, | 524 | Model: parent.Model, | ||
| 523 | Provider: parent.Provider, | 525 | Provider: parent.Provider, | ||
| 524 | - | Started: time.Now().UTC(), | 526 | + | Started: now.UTC(), |
| 525 | Version: version, | 527 | Version: version, | ||
| 528 | + | Timezone: timezone, | |||
| 529 | + | TimezoneOffset: timezoneOffset, | |||
| 526 | Parent: parent.ID, | 530 | Parent: parent.ID, | ||
| 527 | ForkPoint: limit, | 531 | ForkPoint: limit, | ||
| 528 | HideFromSessions: hideFromSessions, | 532 | HideFromSessions: hideFromSessions, | ||
M packages/core/session_usage_test.go +39 -0
| @@ -7,6 +7,45 @@ import ( | |||
| 7 | 7 | "github.com/bnema/zut/packages/provider" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) { | |
| 11 | + | sess := newUsageTestSession(t) | |
| 12 | + | if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | first := provider.Usage{ | |
| 16 | + | InputTokens: 60, | |
| 17 | + | CacheReadTokens: 40, | |
| 18 | + | CacheMeasuredPromptTokens: 100, | |
| 19 | + | CacheMeasuredReadTokens: 40, | |
| 20 | + | } | |
| 21 | + | if err := sess.AppendUsage(first, first); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | second := provider.Usage{ | |
| 25 | + | InputTokens: 80, | |
| 26 | + | CacheReadTokens: 70, | |
| 27 | + | CacheMeasuredPromptTokens: 150, | |
| 28 | + | CacheMeasuredReadTokens: 70, | |
| 29 | + | } | |
| 30 | + | if err := sess.AppendUsage(provider.Usage{}, second); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if err := sess.Close(); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | cumulative, last, err := SessionUsageDetail(sess.Path) | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 { | |
| 42 | + | t.Fatalf("cumulative measured cache usage = %+v", cumulative) | |
| 43 | + | } | |
| 44 | + | if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 { | |
| 45 | + | t.Fatalf("last measured cache usage = %+v", last) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 10 | 49 | func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) { | |
| 11 | 50 | t.Run("compaction is latest row", func(t *testing.T) { | |
| 12 | 51 | sess := newUsageTestSession(t) | |
| @@ -7,6 +7,45 @@ import ( | |||||
| 7 | "github.com/bnema/zut/packages/provider" | 7 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) { | |||
| 11 | + | sess := newUsageTestSession(t) | |||
| 12 | + | if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } | |||
| 15 | + | first := provider.Usage{ | |||
| 16 | + | InputTokens: 60, | |||
| 17 | + | CacheReadTokens: 40, | |||
| 18 | + | CacheMeasuredPromptTokens: 100, | |||
| 19 | + | CacheMeasuredReadTokens: 40, | |||
| 20 | + | } | |||
| 21 | + | if err := sess.AppendUsage(first, first); err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | second := provider.Usage{ | |||
| 25 | + | InputTokens: 80, | |||
| 26 | + | CacheReadTokens: 70, | |||
| 27 | + | CacheMeasuredPromptTokens: 150, | |||
| 28 | + | CacheMeasuredReadTokens: 70, | |||
| 29 | + | } | |||
| 30 | + | if err := sess.AppendUsage(provider.Usage{}, second); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | if err := sess.Close(); err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | cumulative, last, err := SessionUsageDetail(sess.Path) | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 { | |||
| 42 | + | t.Fatalf("cumulative measured cache usage = %+v", cumulative) | |||
| 43 | + | } | |||
| 44 | + | if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 { | |||
| 45 | + | t.Fatalf("last measured cache usage = %+v", last) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 10 | func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) { | 49 | func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) { | ||
| 11 | t.Run("compaction is latest row", func(t *testing.T) { | 50 | t.Run("compaction is latest row", func(t *testing.T) { | ||
| 12 | sess := newUsageTestSession(t) | 51 | sess := newUsageTestSession(t) | ||
M packages/core/time_context.go +12 -1
| @@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string | |||
| 65 | 65 | zone: timezone, | |
| 66 | 66 | offset: offset, | |
| 67 | 67 | } | |
| 68 | + | a.hasSessionTimeContext = true | |
| 68 | 69 | a.mu.Unlock() | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | func (a *Agent) providerTimeContext() agentTimeContext { | |
| 72 | 73 | a.mu.Lock() | |
| 73 | 74 | defer a.mu.Unlock() | |
| 75 | + | if !a.hasSessionTimeContext { | |
| 76 | + | return agentTimeContext{} | |
| 77 | + | } | |
| 74 | 78 | return a.timeContext | |
| 75 | 79 | } | |
| 76 | 80 | ||
| 77 | 81 | func (c agentTimeContext) systemText() string { | |
| 82 | + | return c.developerText() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // developerText returns host context projected after stable instructions. | |
| 86 | + | // Minute precision is sufficient for coordination and avoids generating a | |
| 87 | + | // unique timestamp solely to distinguish otherwise-equal agents. | |
| 88 | + | func (c agentTimeContext) developerText() string { | |
| 78 | 89 | if c.started.IsZero() { | |
| 79 | 90 | return "" | |
| 80 | 91 | } | |
| @@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string { | |||
| 91 | 102 | _, seconds := c.started.In(location).Zone() | |
| 92 | 103 | offset = formatUTCOffset(seconds) | |
| 93 | 104 | } | |
| 94 | - | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset) | |
| 105 | + | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset) | |
| 95 | 106 | } | |
| 96 | 107 | ||
| 97 | 108 | func formatUTCOffset(seconds int) string { | |
| @@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string | |||||
| 65 | zone: timezone, | 65 | zone: timezone, | ||
| 66 | offset: offset, | 66 | offset: offset, | ||
| 67 | } | 67 | } | ||
| 68 | + | a.hasSessionTimeContext = true | |||
| 68 | a.mu.Unlock() | 69 | a.mu.Unlock() | ||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
| 71 | func (a *Agent) providerTimeContext() agentTimeContext { | 72 | func (a *Agent) providerTimeContext() agentTimeContext { | ||
| 72 | a.mu.Lock() | 73 | a.mu.Lock() | ||
| 73 | defer a.mu.Unlock() | 74 | defer a.mu.Unlock() | ||
| 75 | + | if !a.hasSessionTimeContext { | |||
| 76 | + | return agentTimeContext{} | |||
| 77 | + | } | |||
| 74 | return a.timeContext | 78 | return a.timeContext | ||
| 75 | } | 79 | } | ||
| 76 | 80 | ||||
| 77 | func (c agentTimeContext) systemText() string { | 81 | func (c agentTimeContext) systemText() string { | ||
| 82 | + | return c.developerText() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // developerText returns host context projected after stable instructions. | |||
| 86 | + | // Minute precision is sufficient for coordination and avoids generating a | |||
| 87 | + | // unique timestamp solely to distinguish otherwise-equal agents. | |||
| 88 | + | func (c agentTimeContext) developerText() string { | |||
| 78 | if c.started.IsZero() { | 89 | if c.started.IsZero() { | ||
| 79 | return "" | 90 | return "" | ||
| 80 | } | 91 | } | ||
| @@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string { | |||||
| 91 | _, seconds := c.started.In(location).Zone() | 102 | _, seconds := c.started.In(location).Zone() | ||
| 92 | offset = formatUTCOffset(seconds) | 103 | offset = formatUTCOffset(seconds) | ||
| 93 | } | 104 | } | ||
| 94 | - | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset) | 105 | + | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset) |
| 95 | } | 106 | } | ||
| 96 | 107 | ||||
| 97 | func formatUTCOffset(seconds int) string { | 108 | func formatUTCOffset(seconds int) string { | ||
M packages/provider/amazon_bedrock.go +7 -3
| @@ -333,8 +333,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 333 | 333 | reasoning := ClampReasoningForModel(model, req.Reasoning) | |
| 334 | 334 | adaptive := usesAdaptiveThinking(model) | |
| 335 | 335 | ||
| 336 | - | if system := req.SystemPrompt(); system != "" { | |
| 337 | - | sysBlock := map[string]interface{}{"text": system} | |
| 336 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) | |
| 337 | + | if stableSystem != "" { | |
| 338 | + | sysBlock := map[string]interface{}{"text": stableSystem} | |
| 338 | 339 | if caching { | |
| 339 | 340 | // Append cachePoint after the system text so the stable system | |
| 340 | 341 | // prompt is cached as the first breakpoint. | |
| @@ -343,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 343 | 344 | out.System = []map[string]interface{}{sysBlock} | |
| 344 | 345 | } | |
| 345 | 346 | } | |
| 347 | + | if dynamicSystem != "" { | |
| 348 | + | out.System = append(out.System, map[string]interface{}{"text": dynamicSystem}) | |
| 349 | + | } | |
| 346 | 350 | if !adaptive { | |
| 347 | 351 | out.InferenceConfig.Temperature = req.Temperature | |
| 348 | 352 | } | |
| @@ -356,7 +360,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 356 | 360 | if out.InferenceConfig.MaxTokens == 0 { | |
| 357 | 361 | out.InferenceConfig.MaxTokens = 4096 | |
| 358 | 362 | } | |
| 359 | - | for _, m := range normalizeBedrockToolResults(req.Messages) { | |
| 363 | + | for _, m := range normalizeBedrockToolResults(messages) { | |
| 360 | 364 | role := string(m.Role) | |
| 361 | 365 | if role == "tool" { | |
| 362 | 366 | role = "user" | |
| @@ -333,8 +333,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 333 | reasoning := ClampReasoningForModel(model, req.Reasoning) | 333 | reasoning := ClampReasoningForModel(model, req.Reasoning) | ||
| 334 | adaptive := usesAdaptiveThinking(model) | 334 | adaptive := usesAdaptiveThinking(model) | ||
| 335 | 335 | ||||
| 336 | - | if system := req.SystemPrompt(); system != "" { | 336 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) |
| 337 | - | sysBlock := map[string]interface{}{"text": system} | 337 | + | if stableSystem != "" { |
| 338 | + | sysBlock := map[string]interface{}{"text": stableSystem} | |||
| 338 | if caching { | 339 | if caching { | ||
| 339 | // Append cachePoint after the system text so the stable system | 340 | // Append cachePoint after the system text so the stable system | ||
| 340 | // prompt is cached as the first breakpoint. | 341 | // prompt is cached as the first breakpoint. | ||
| @@ -343,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 343 | out.System = []map[string]interface{}{sysBlock} | 344 | out.System = []map[string]interface{}{sysBlock} | ||
| 344 | } | 345 | } | ||
| 345 | } | 346 | } | ||
| 347 | + | if dynamicSystem != "" { | |||
| 348 | + | out.System = append(out.System, map[string]interface{}{"text": dynamicSystem}) | |||
| 349 | + | } | |||
| 346 | if !adaptive { | 350 | if !adaptive { | ||
| 347 | out.InferenceConfig.Temperature = req.Temperature | 351 | out.InferenceConfig.Temperature = req.Temperature | ||
| 348 | } | 352 | } | ||
| @@ -356,7 +360,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 356 | if out.InferenceConfig.MaxTokens == 0 { | 360 | if out.InferenceConfig.MaxTokens == 0 { | ||
| 357 | out.InferenceConfig.MaxTokens = 4096 | 361 | out.InferenceConfig.MaxTokens = 4096 | ||
| 358 | } | 362 | } | ||
| 359 | - | for _, m := range normalizeBedrockToolResults(req.Messages) { | 363 | + | for _, m := range normalizeBedrockToolResults(messages) { |
| 360 | role := string(m.Role) | 364 | role := string(m.Role) | ||
| 361 | if role == "tool" { | 365 | if role == "tool" { | ||
| 362 | role = "user" | 366 | role = "user" | ||
M packages/provider/anthropic.go +11 -8
| @@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 246 | 246 | out.Temperature = req.Temperature | |
| 247 | 247 | } | |
| 248 | 248 | ||
| 249 | - | system := req.SystemPrompt() | |
| 249 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) | |
| 250 | 250 | ||
| 251 | 251 | // System prompt assembly differs between api-key and OAuth modes. | |
| 252 | 252 | // OAuth requests MUST begin with the Claude Code identity line or | |
| @@ -272,20 +272,23 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 272 | 272 | Text: claudeCodeIdentity, | |
| 273 | 273 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 274 | 274 | }} | |
| 275 | - | if system != "" { | |
| 275 | + | if stableSystem != "" { | |
| 276 | 276 | out.System = append(out.System, anthSystemBlock{ | |
| 277 | 277 | Type: "text", | |
| 278 | - | Text: system, | |
| 278 | + | Text: stableSystem, | |
| 279 | 279 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 280 | 280 | }) | |
| 281 | 281 | } | |
| 282 | - | } else if system != "" { | |
| 282 | + | } else if stableSystem != "" { | |
| 283 | 283 | out.System = []anthSystemBlock{{ | |
| 284 | 284 | Type: "text", | |
| 285 | - | Text: system, | |
| 285 | + | Text: stableSystem, | |
| 286 | 286 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 287 | 287 | }} | |
| 288 | 288 | } | |
| 289 | + | if dynamicSystem != "" { | |
| 290 | + | out.System = append(out.System, anthSystemBlock{Type: "text", Text: dynamicSystem}) | |
| 291 | + | } | |
| 289 | 292 | ||
| 290 | 293 | if reasoning != "" && m.Reasoning { | |
| 291 | 294 | if adaptive { | |
| @@ -318,7 +321,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 318 | 321 | } | |
| 319 | 322 | } | |
| 320 | 323 | ||
| 321 | - | for _, t := range activeToolDefinitions(req.Tools, req.Messages) { | |
| 324 | + | for _, t := range activeToolDefinitions(req.Tools, messages) { | |
| 322 | 325 | name := t.Name | |
| 323 | 326 | if c.oauthTok != "" { | |
| 324 | 327 | name = toClaudeCodeToolName(name) | |
| @@ -353,8 +356,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 353 | 356 | // emitting them separately keeps each message bit-stable across | |
| 354 | 357 | // turns, so the cache prefix matches for the entire history up | |
| 355 | 358 | // to the newest block. | |
| 356 | - | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 357 | - | for _, msg := range req.Messages { | |
| 359 | + | messages = RepairOrphanedToolResults(messages) | |
| 360 | + | for _, msg := range messages { | |
| 358 | 361 | renameTools := c.oauthTok != "" | |
| 359 | 362 | switch msg.Role { | |
| 360 | 363 | case RoleUser: | |
| @@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 246 | out.Temperature = req.Temperature | 246 | out.Temperature = req.Temperature | ||
| 247 | } | 247 | } | ||
| 248 | 248 | ||||
| 249 | - | system := req.SystemPrompt() | 249 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) |
| 250 | 250 | ||||
| 251 | // System prompt assembly differs between api-key and OAuth modes. | 251 | // System prompt assembly differs between api-key and OAuth modes. | ||
| 252 | // OAuth requests MUST begin with the Claude Code identity line or | 252 | // OAuth requests MUST begin with the Claude Code identity line or | ||
| @@ -272,20 +272,23 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 272 | Text: claudeCodeIdentity, | 272 | Text: claudeCodeIdentity, | ||
| 273 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | 273 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | ||
| 274 | }} | 274 | }} | ||
| 275 | - | if system != "" { | 275 | + | if stableSystem != "" { |
| 276 | out.System = append(out.System, anthSystemBlock{ | 276 | out.System = append(out.System, anthSystemBlock{ | ||
| 277 | Type: "text", | 277 | Type: "text", | ||
| 278 | - | Text: system, | 278 | + | Text: stableSystem, |
| 279 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | 279 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | ||
| 280 | }) | 280 | }) | ||
| 281 | } | 281 | } | ||
| 282 | - | } else if system != "" { | 282 | + | } else if stableSystem != "" { |
| 283 | out.System = []anthSystemBlock{{ | 283 | out.System = []anthSystemBlock{{ | ||
| 284 | Type: "text", | 284 | Type: "text", | ||
| 285 | - | Text: system, | 285 | + | Text: stableSystem, |
| 286 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | 286 | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | ||
| 287 | }} | 287 | }} | ||
| 288 | } | 288 | } | ||
| 289 | + | if dynamicSystem != "" { | |||
| 290 | + | out.System = append(out.System, anthSystemBlock{Type: "text", Text: dynamicSystem}) | |||
| 291 | + | } | |||
| 289 | 292 | ||||
| 290 | if reasoning != "" && m.Reasoning { | 293 | if reasoning != "" && m.Reasoning { | ||
| 291 | if adaptive { | 294 | if adaptive { | ||
| @@ -318,7 +321,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 318 | } | 321 | } | ||
| 319 | } | 322 | } | ||
| 320 | 323 | ||||
| 321 | - | for _, t := range activeToolDefinitions(req.Tools, req.Messages) { | 324 | + | for _, t := range activeToolDefinitions(req.Tools, messages) { |
| 322 | name := t.Name | 325 | name := t.Name | ||
| 323 | if c.oauthTok != "" { | 326 | if c.oauthTok != "" { | ||
| 324 | name = toClaudeCodeToolName(name) | 327 | name = toClaudeCodeToolName(name) | ||
| @@ -353,8 +356,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 353 | // emitting them separately keeps each message bit-stable across | 356 | // emitting them separately keeps each message bit-stable across | ||
| 354 | // turns, so the cache prefix matches for the entire history up | 357 | // turns, so the cache prefix matches for the entire history up | ||
| 355 | // to the newest block. | 358 | // to the newest block. | ||
| 356 | - | req.Messages = RepairOrphanedToolResults(req.Messages) | 359 | + | messages = RepairOrphanedToolResults(messages) |
| 357 | - | for _, msg := range req.Messages { | 360 | + | for _, msg := range messages { |
| 358 | renameTools := c.oauthTok != "" | 361 | renameTools := c.oauthTok != "" | ||
| 359 | switch msg.Role { | 362 | switch msg.Role { | ||
| 360 | case RoleUser: | 363 | case RoleUser: | ||
A packages/provider/developer_context.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // systemWithDeveloperContext projects host-authored developer messages onto | |
| 6 | + | // providers that expose only top-level system instructions. Stable instructions | |
| 7 | + | // remain separate so cache-aware adapters can place their cache boundary before | |
| 8 | + | // the model-only and per-turn context. OpenAI adapters preserve RoleDeveloper | |
| 9 | + | // as a native developer input instead. | |
| 10 | + | func systemWithDeveloperContext(req Request) (stable, dynamic string, messages []Message) { | |
| 11 | + | messages = make([]Message, 0, len(req.Messages)) | |
| 12 | + | context := make([]string, 0, len(req.Messages)+1) | |
| 13 | + | if text := strings.TrimSpace(req.SystemContext); text != "" { | |
| 14 | + | context = append(context, text) | |
| 15 | + | } | |
| 16 | + | for _, message := range req.Messages { | |
| 17 | + | if message.Role != RoleDeveloper { | |
| 18 | + | messages = append(messages, message) | |
| 19 | + | continue | |
| 20 | + | } | |
| 21 | + | for _, block := range message.Content { | |
| 22 | + | if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" { | |
| 23 | + | context = append(context, text.Text) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // systemWithDeveloperContext projects host-authored developer messages onto | |||
| 6 | + | // providers that expose only top-level system instructions. Stable instructions | |||
| 7 | + | // remain separate so cache-aware adapters can place their cache boundary before | |||
| 8 | + | // the model-only and per-turn context. OpenAI adapters preserve RoleDeveloper | |||
| 9 | + | // as a native developer input instead. | |||
| 10 | + | func systemWithDeveloperContext(req Request) (stable, dynamic string, messages []Message) { | |||
| 11 | + | messages = make([]Message, 0, len(req.Messages)) | |||
| 12 | + | context := make([]string, 0, len(req.Messages)+1) | |||
| 13 | + | if text := strings.TrimSpace(req.SystemContext); text != "" { | |||
| 14 | + | context = append(context, text) | |||
| 15 | + | } | |||
| 16 | + | for _, message := range req.Messages { | |||
| 17 | + | if message.Role != RoleDeveloper { | |||
| 18 | + | messages = append(messages, message) | |||
| 19 | + | continue | |||
| 20 | + | } | |||
| 21 | + | for _, block := range message.Content { | |||
| 22 | + | if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" { | |||
| 23 | + | context = append(context, text.Text) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages | |||
| 28 | + | } | |||
A packages/provider/developer_context_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) { | |
| 6 | + | stable, dynamic, messages := systemWithDeveloperContext(Request{ | |
| 7 | + | System: "stable instructions", | |
| 8 | + | SystemContext: "legacy host context", | |
| 9 | + | Messages: []Message{ | |
| 10 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |
| 11 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |
| 12 | + | }, | |
| 13 | + | }) | |
| 14 | + | if stable != "stable instructions" { | |
| 15 | + | t.Fatalf("stable = %q", stable) | |
| 16 | + | } | |
| 17 | + | if dynamic != "legacy host context\n\ndynamic host context" { | |
| 18 | + | t.Fatalf("dynamic = %q", dynamic) | |
| 19 | + | } | |
| 20 | + | if len(messages) != 1 || messages[0].Role != RoleUser { | |
| 21 | + | t.Fatalf("messages = %#v, want only the user task", messages) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestAnthropicDeveloperContextFollowsStableCacheBoundary(t *testing.T) { | |
| 26 | + | req, err := (&anthropicClient{}).buildRequest(Request{ | |
| 27 | + | Model: "claude-sonnet-4-5", | |
| 28 | + | System: "stable instructions", | |
| 29 | + | Messages: []Message{ | |
| 30 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |
| 31 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |
| 32 | + | }, | |
| 33 | + | }) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if len(req.System) != 2 || req.System[0].Text != "stable instructions" || req.System[0].CacheControl == nil || req.System[1].Text != "dynamic host context" || req.System[1].CacheControl != nil { | |
| 38 | + | t.Fatalf("system blocks = %#v", req.System) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestBedrockDeveloperContextFollowsStableCacheBoundary(t *testing.T) { | |
| 43 | + | req, err := (&bedrockClient{region: "us-east-1"}).buildRequest(Request{ | |
| 44 | + | Model: "anthropic.claude-sonnet-4-5-20250929-v1:0", | |
| 45 | + | System: "stable instructions", | |
| 46 | + | Messages: []Message{ | |
| 47 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |
| 48 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |
| 49 | + | }, | |
| 50 | + | }) | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | if len(req.System) != 3 || req.System[0]["text"] != "stable instructions" || req.System[1]["cachePoint"] == nil || req.System[2]["text"] != "dynamic host context" { | |
| 55 | + | t.Fatalf("system blocks = %#v", req.System) | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) { | |||
| 6 | + | stable, dynamic, messages := systemWithDeveloperContext(Request{ | |||
| 7 | + | System: "stable instructions", | |||
| 8 | + | SystemContext: "legacy host context", | |||
| 9 | + | Messages: []Message{ | |||
| 10 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |||
| 11 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |||
| 12 | + | }, | |||
| 13 | + | }) | |||
| 14 | + | if stable != "stable instructions" { | |||
| 15 | + | t.Fatalf("stable = %q", stable) | |||
| 16 | + | } | |||
| 17 | + | if dynamic != "legacy host context\n\ndynamic host context" { | |||
| 18 | + | t.Fatalf("dynamic = %q", dynamic) | |||
| 19 | + | } | |||
| 20 | + | if len(messages) != 1 || messages[0].Role != RoleUser { | |||
| 21 | + | t.Fatalf("messages = %#v, want only the user task", messages) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestAnthropicDeveloperContextFollowsStableCacheBoundary(t *testing.T) { | |||
| 26 | + | req, err := (&anthropicClient{}).buildRequest(Request{ | |||
| 27 | + | Model: "claude-sonnet-4-5", | |||
| 28 | + | System: "stable instructions", | |||
| 29 | + | Messages: []Message{ | |||
| 30 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |||
| 31 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |||
| 32 | + | }, | |||
| 33 | + | }) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if len(req.System) != 2 || req.System[0].Text != "stable instructions" || req.System[0].CacheControl == nil || req.System[1].Text != "dynamic host context" || req.System[1].CacheControl != nil { | |||
| 38 | + | t.Fatalf("system blocks = %#v", req.System) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestBedrockDeveloperContextFollowsStableCacheBoundary(t *testing.T) { | |||
| 43 | + | req, err := (&bedrockClient{region: "us-east-1"}).buildRequest(Request{ | |||
| 44 | + | Model: "anthropic.claude-sonnet-4-5-20250929-v1:0", | |||
| 45 | + | System: "stable instructions", | |||
| 46 | + | Messages: []Message{ | |||
| 47 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |||
| 48 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |||
| 49 | + | }, | |||
| 50 | + | }) | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | if len(req.System) != 3 || req.System[0]["text"] != "stable instructions" || req.System[1]["cachePoint"] == nil || req.System[2]["text"] != "dynamic host context" { | |||
| 55 | + | t.Fatalf("system blocks = %#v", req.System) | |||
| 56 | + | } | |||
| 57 | + | } | |||
M packages/provider/gemini.go +6 -4
| @@ -142,17 +142,19 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 145 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) | |
| 146 | + | system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n")) | |
| 145 | 147 | out := &gemRequest{} | |
| 146 | 148 | ||
| 147 | 149 | // System prompt → systemInstruction.parts[0].text. | |
| 148 | - | if system := req.SystemPrompt(); strings.TrimSpace(system) != "" { | |
| 150 | + | if system != "" { | |
| 149 | 151 | out.SystemInstruction = &gemSystemInstruction{ | |
| 150 | 152 | Parts: []gemPart{{Text: system}}, | |
| 151 | 153 | } | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 154 | 156 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | |
| 155 | - | activeTools := activeToolDefinitions(req.Tools, req.Messages) | |
| 157 | + | activeTools := activeToolDefinitions(req.Tools, messages) | |
| 156 | 158 | ||
| 157 | 159 | // Convert tool defs. Gemini image-generation models reject function | |
| 158 | 160 | // declarations with "Function calling is not enabled for this model"; | |
| @@ -190,9 +192,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 190 | 192 | // Convert messages. When function calling is disabled for the target | |
| 191 | 193 | // model, also remove historical functionCall/functionResponse parts; | |
| 192 | 194 | // image models should receive only text/image content. | |
| 193 | - | msgs := req.Messages | |
| 195 | + | msgs := messages | |
| 194 | 196 | if functionsEnabled { | |
| 195 | - | msgs = RepairOrphanedToolResults(req.Messages) | |
| 197 | + | msgs = RepairOrphanedToolResults(messages) | |
| 196 | 198 | } | |
| 197 | 199 | for _, msg := range msgs { | |
| 198 | 200 | switch msg.Role { | |
| @@ -142,17 +142,19 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | reasoning := ClampReasoningForModel(m, req.Reasoning) | 144 | reasoning := ClampReasoningForModel(m, req.Reasoning) | ||
| 145 | + | stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req) | |||
| 146 | + | system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n")) | |||
| 145 | out := &gemRequest{} | 147 | out := &gemRequest{} | ||
| 146 | 148 | ||||
| 147 | // System prompt → systemInstruction.parts[0].text. | 149 | // System prompt → systemInstruction.parts[0].text. | ||
| 148 | - | if system := req.SystemPrompt(); strings.TrimSpace(system) != "" { | 150 | + | if system != "" { |
| 149 | out.SystemInstruction = &gemSystemInstruction{ | 151 | out.SystemInstruction = &gemSystemInstruction{ | ||
| 150 | Parts: []gemPart{{Text: system}}, | 152 | Parts: []gemPart{{Text: system}}, | ||
| 151 | } | 153 | } | ||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 154 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | 156 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | ||
| 155 | - | activeTools := activeToolDefinitions(req.Tools, req.Messages) | 157 | + | activeTools := activeToolDefinitions(req.Tools, messages) |
| 156 | 158 | ||||
| 157 | // Convert tool defs. Gemini image-generation models reject function | 159 | // Convert tool defs. Gemini image-generation models reject function | ||
| 158 | // declarations with "Function calling is not enabled for this model"; | 160 | // declarations with "Function calling is not enabled for this model"; | ||
| @@ -190,9 +192,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 190 | // Convert messages. When function calling is disabled for the target | 192 | // Convert messages. When function calling is disabled for the target | ||
| 191 | // model, also remove historical functionCall/functionResponse parts; | 193 | // model, also remove historical functionCall/functionResponse parts; | ||
| 192 | // image models should receive only text/image content. | 194 | // image models should receive only text/image content. | ||
| 193 | - | msgs := req.Messages | 195 | + | msgs := messages |
| 194 | if functionsEnabled { | 196 | if functionsEnabled { | ||
| 195 | - | msgs = RepairOrphanedToolResults(req.Messages) | 197 | + | msgs = RepairOrphanedToolResults(messages) |
| 196 | } | 198 | } | ||
| 197 | for _, msg := range msgs { | 199 | for _, msg := range msgs { | ||
| 198 | switch msg.Role { | 200 | switch msg.Role { | ||
M packages/provider/openai.go +7 -11
| @@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 299 | 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 300 | 300 | for _, msg := range req.Messages { | |
| 301 | 301 | switch msg.Role { | |
| 302 | + | case RoleDeveloper: | |
| 303 | + | content := buildOAIUserContent(msg.Content, textOnly) | |
| 304 | + | out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content}) | |
| 302 | 305 | case RoleUser: | |
| 303 | 306 | content := buildOAIUserContent(msg.Content, textOnly) | |
| 304 | 307 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | |
| @@ -656,11 +659,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 656 | 659 | FinishReason string `json:"finish_reason"` | |
| 657 | 660 | } `json:"choices"` | |
| 658 | 661 | Usage *struct { | |
| 659 | - | PromptTokens int `json:"prompt_tokens"` | |
| 660 | - | CompletionTokens int `json:"completion_tokens"` | |
| 661 | - | PromptTokensDetails struct { | |
| 662 | - | CachedTokens int `json:"cached_tokens"` | |
| 663 | - | } `json:"prompt_tokens_details"` | |
| 662 | + | PromptTokens int `json:"prompt_tokens"` | |
| 663 | + | CompletionTokens int `json:"completion_tokens"` | |
| 664 | + | PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"` | |
| 664 | 665 | CompletionTokensDetails *struct { | |
| 665 | 666 | ReasoningTokens int `json:"reasoning_tokens"` | |
| 666 | 667 | } `json:"completion_tokens_details"` | |
| @@ -680,12 +681,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 680 | 681 | return | |
| 681 | 682 | } | |
| 682 | 683 | if chunk.Usage != nil { | |
| 683 | - | usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens | |
| 684 | - | if usage.InputTokens < 0 { | |
| 685 | - | usage.InputTokens = chunk.Usage.PromptTokens | |
| 686 | - | } | |
| 687 | - | usage.OutputTokens = chunk.Usage.CompletionTokens | |
| 688 | - | usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens | |
| 684 | + | usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails) | |
| 689 | 685 | if details := chunk.Usage.CompletionTokensDetails; details != nil { | |
| 690 | 686 | usage.ReasoningTokens = details.ReasoningTokens | |
| 691 | 687 | usage.ReasoningTokensKnown = true | |
| @@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | ||
| 300 | for _, msg := range req.Messages { | 300 | for _, msg := range req.Messages { | ||
| 301 | switch msg.Role { | 301 | switch msg.Role { | ||
| 302 | + | case RoleDeveloper: | |||
| 303 | + | content := buildOAIUserContent(msg.Content, textOnly) | |||
| 304 | + | out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content}) | |||
| 302 | case RoleUser: | 305 | case RoleUser: | ||
| 303 | content := buildOAIUserContent(msg.Content, textOnly) | 306 | content := buildOAIUserContent(msg.Content, textOnly) | ||
| 304 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | 307 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | ||
| @@ -656,11 +659,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 656 | FinishReason string `json:"finish_reason"` | 659 | FinishReason string `json:"finish_reason"` | ||
| 657 | } `json:"choices"` | 660 | } `json:"choices"` | ||
| 658 | Usage *struct { | 661 | Usage *struct { | ||
| 659 | - | PromptTokens int `json:"prompt_tokens"` | 662 | + | PromptTokens int `json:"prompt_tokens"` |
| 660 | - | CompletionTokens int `json:"completion_tokens"` | 663 | + | CompletionTokens int `json:"completion_tokens"` |
| 661 | - | PromptTokensDetails struct { | 664 | + | PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"` |
| 662 | - | CachedTokens int `json:"cached_tokens"` | |||
| 663 | - | } `json:"prompt_tokens_details"` | |||
| 664 | CompletionTokensDetails *struct { | 665 | CompletionTokensDetails *struct { | ||
| 665 | ReasoningTokens int `json:"reasoning_tokens"` | 666 | ReasoningTokens int `json:"reasoning_tokens"` | ||
| 666 | } `json:"completion_tokens_details"` | 667 | } `json:"completion_tokens_details"` | ||
| @@ -680,12 +681,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 680 | return | 681 | return | ||
| 681 | } | 682 | } | ||
| 682 | if chunk.Usage != nil { | 683 | if chunk.Usage != nil { | ||
| 683 | - | usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens | 684 | + | usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails) |
| 684 | - | if usage.InputTokens < 0 { | |||
| 685 | - | usage.InputTokens = chunk.Usage.PromptTokens | |||
| 686 | - | } | |||
| 687 | - | usage.OutputTokens = chunk.Usage.CompletionTokens | |||
| 688 | - | usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens | |||
| 689 | if details := chunk.Usage.CompletionTokensDetails; details != nil { | 685 | if details := chunk.Usage.CompletionTokensDetails; details != nil { | ||
| 690 | usage.ReasoningTokens = details.ReasoningTokens | 686 | usage.ReasoningTokens = details.ReasoningTokens | ||
| 691 | usage.ReasoningTokensKnown = true | 687 | usage.ReasoningTokensKnown = true | ||
M packages/provider/openai_codex.go +102 -38
| @@ -51,8 +51,11 @@ type codexClient struct { | |||
| 51 | 51 | // responsesCapabilities records extensions verified for the exact provider | |
| 52 | 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | |
| 53 | 53 | type responsesCapabilities struct { | |
| 54 | - | StablePromptCacheKey bool | |
| 55 | - | SessionHeader bool | |
| 54 | + | StablePromptCacheKey bool | |
| 55 | + | ExplicitPromptCacheBreakpoint bool | |
| 56 | + | SessionHeader bool | |
| 57 | + | ThreadHeader bool | |
| 58 | + | ClientRequestHeader bool | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 58 | 61 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | |
| @@ -78,6 +81,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||
| 78 | 81 | capabilities: responsesCapabilities{ | |
| 79 | 82 | StablePromptCacheKey: true, | |
| 80 | 83 | SessionHeader: true, | |
| 84 | + | ThreadHeader: true, | |
| 85 | + | ClientRequestHeader: true, | |
| 81 | 86 | }, | |
| 82 | 87 | http: &http.Client{Timeout: 0}, | |
| 83 | 88 | } | |
| @@ -88,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" } | |||
| 88 | 93 | // ---- Responses API wire types (subset needed for zut's surface) ---- | |
| 89 | 94 | ||
| 90 | 95 | type codexInputText struct { | |
| 91 | - | Type string `json:"type"` // "input_text" | |
| 92 | - | Text string `json:"text"` | |
| 96 | + | Type string `json:"type"` // "input_text" | |
| 97 | + | Text string `json:"text"` | |
| 98 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |
| 93 | 99 | } | |
| 94 | 100 | ||
| 95 | 101 | type codexInputImage struct { | |
| @@ -104,6 +110,14 @@ type codexOutputText struct { | |||
| 104 | 110 | Annotations []any `json:"annotations"` | |
| 105 | 111 | } | |
| 106 | 112 | ||
| 113 | + | type codexPromptCacheOptions struct { | |
| 114 | + | Mode string `json:"mode"` | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | type codexPromptCacheBreakpoint struct { | |
| 118 | + | Mode string `json:"mode"` | |
| 119 | + | } | |
| 120 | + | ||
| 107 | 121 | type codexInputMessage struct { | |
| 108 | 122 | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 109 | 123 | Role string `json:"role"` | |
| @@ -162,19 +176,20 @@ type codexReasoningConfig struct { | |||
| 162 | 176 | } | |
| 163 | 177 | ||
| 164 | 178 | type codexRequest struct { | |
| 165 | - | Model string `json:"model"` | |
| 166 | - | Store bool `json:"store"` | |
| 167 | - | Stream bool `json:"stream,omitempty"` | |
| 168 | - | Instructions string `json:"instructions,omitempty"` | |
| 169 | - | Input []any `json:"input"` | |
| 170 | - | Tools []codexTool `json:"tools,omitempty"` | |
| 171 | - | ToolChoice string `json:"tool_choice,omitempty"` | |
| 172 | - | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 173 | - | Include []string `json:"include,omitempty"` | |
| 174 | - | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 175 | - | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 176 | - | ServiceTier string `json:"service_tier,omitempty"` | |
| 177 | - | PreviousResponseID string `json:"previous_response_id,omitempty"` | |
| 179 | + | Model string `json:"model"` | |
| 180 | + | Store bool `json:"store"` | |
| 181 | + | Stream bool `json:"stream,omitempty"` | |
| 182 | + | Instructions string `json:"instructions,omitempty"` | |
| 183 | + | Input []any `json:"input"` | |
| 184 | + | Tools []codexTool `json:"tools,omitempty"` | |
| 185 | + | ToolChoice string `json:"tool_choice,omitempty"` | |
| 186 | + | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 187 | + | Include []string `json:"include,omitempty"` | |
| 188 | + | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 189 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 190 | + | PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"` | |
| 191 | + | ServiceTier string `json:"service_tier,omitempty"` | |
| 192 | + | PreviousResponseID string `json:"previous_response_id,omitempty"` | |
| 178 | 193 | } | |
| 179 | 194 | ||
| 180 | 195 | // ---- Request building ---- | |
| @@ -191,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||
| 191 | 206 | return FindModel("openai", id) | |
| 192 | 207 | } | |
| 193 | 208 | ||
| 209 | + | func isGPT56Model(model string) bool { | |
| 210 | + | return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-") | |
| 211 | + | } | |
| 212 | + | ||
| 194 | 213 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 195 | 214 | providerName := c.providerName | |
| 196 | 215 | if providerName == "" { | |
| @@ -216,6 +235,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 216 | 235 | if req.FastMode { | |
| 217 | 236 | body.ServiceTier = fastModeServiceTier | |
| 218 | 237 | } | |
| 238 | + | if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) { | |
| 239 | + | // Keep implicit mode so OpenAI can retain later conversation | |
| 240 | + | // checkpoints as well as this stable instruction/tool boundary. | |
| 241 | + | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | |
| 242 | + | body.Input = append(body.Input, codexInputMessage{ | |
| 243 | + | Role: "developer", | |
| 244 | + | Content: []any{codexInputText{ | |
| 245 | + | Type: "input_text", | |
| 246 | + | Text: "[Stable prompt boundary]", | |
| 247 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"}, | |
| 248 | + | }}, | |
| 249 | + | }) | |
| 250 | + | } | |
| 219 | 251 | if m.Reasoning { | |
| 220 | 252 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | |
| 221 | 253 | if hasReasoningLevelOverride(m, req.Reasoning) { | |
| @@ -246,7 +278,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 246 | 278 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 247 | 279 | for _, msg := range req.Messages { | |
| 248 | 280 | switch msg.Role { | |
| 249 | - | case RoleUser: | |
| 281 | + | case RoleDeveloper, RoleUser: | |
| 250 | 282 | content := []any{} | |
| 251 | 283 | for _, c := range msg.Content { | |
| 252 | 284 | switch v := c.(type) { | |
| @@ -262,7 +294,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 262 | 294 | if len(content) == 0 { | |
| 263 | 295 | continue | |
| 264 | 296 | } | |
| 265 | - | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | |
| 297 | + | role := "user" | |
| 298 | + | if msg.Role == RoleDeveloper { | |
| 299 | + | role = "developer" | |
| 300 | + | } | |
| 301 | + | body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) | |
| 266 | 302 | case RoleAssistant: | |
| 267 | 303 | // Emit one output_message per text block, one function_call per | |
| 268 | 304 | // tool call, and one reasoning item per ReasoningBlock, | |
| @@ -363,6 +399,29 @@ func splitCallID(id string) (string, string) { | |||
| 363 | 399 | return id, "" | |
| 364 | 400 | } | |
| 365 | 401 | ||
| 402 | + | const minOpenAIPromptCacheTokens = 1024 | |
| 403 | + | ||
| 404 | + | func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics { | |
| 405 | + | diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation} | |
| 406 | + | if wire == nil || wire.PromptCacheOptions == nil { | |
| 407 | + | return diagnostics | |
| 408 | + | } | |
| 409 | + | diagnostics.Mode = wire.PromptCacheOptions.Mode | |
| 410 | + | diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens | |
| 411 | + | return diagnostics | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // estimatedOpenAIPromptTokens supplies a deliberately conservative local | |
| 415 | + | // eligibility signal. OpenAI makes the final tokenization and cache decision; | |
| 416 | + | // this estimate never leaves the process and is not persisted in diagnostics. | |
| 417 | + | func estimatedOpenAIPromptTokens(wire *codexRequest) int { | |
| 418 | + | encoded, err := json.Marshal(wire) | |
| 419 | + | if err != nil { | |
| 420 | + | return 0 | |
| 421 | + | } | |
| 422 | + | return len(encoded) / 4 | |
| 423 | + | } | |
| 424 | + | ||
| 366 | 425 | func usesCodexCLIRouting(model string) bool { | |
| 367 | 426 | switch model { | |
| 368 | 427 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | |
| @@ -379,13 +438,15 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 379 | 438 | if err != nil { | |
| 380 | 439 | return nil, err | |
| 381 | 440 | } | |
| 441 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full")) | |
| 382 | 442 | if c.modelName != nil { | |
| 383 | 443 | wire.Model = c.modelName(wire.Model) | |
| 384 | 444 | } | |
| 385 | 445 | useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) | |
| 386 | - | logicalSessionID := strings.TrimSpace(req.Context.SessionID) | |
| 387 | - | if c.capabilities.StablePromptCacheKey && logicalSessionID != "" { | |
| 388 | - | wire.PromptCacheKey = logicalSessionID | |
| 446 | + | cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) | |
| 447 | + | threadID := strings.TrimSpace(req.Context.ThreadID) | |
| 448 | + | if c.capabilities.StablePromptCacheKey && cacheSessionID != "" { | |
| 449 | + | wire.PromptCacheKey = cacheSessionID | |
| 389 | 450 | } | |
| 390 | 451 | body, err := json.Marshal(wire) | |
| 391 | 452 | if err != nil { | |
| @@ -408,8 +469,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 408 | 469 | // identities are load-shed with "servers are currently | |
| 409 | 470 | // overloaded" errors even when capacity is fine. | |
| 410 | 471 | httpReq.Header.Set("originator", "codex_cli_rs") | |
| 411 | - | if c.capabilities.SessionHeader && logicalSessionID != "" { | |
| 412 | - | httpReq.Header.Set("session-id", logicalSessionID) | |
| 472 | + | if c.capabilities.SessionHeader && cacheSessionID != "" { | |
| 473 | + | httpReq.Header.Set("session-id", cacheSessionID) | |
| 474 | + | } | |
| 475 | + | if c.capabilities.ThreadHeader && threadID != "" { | |
| 476 | + | httpReq.Header.Set("thread-id", threadID) | |
| 477 | + | } | |
| 478 | + | if c.capabilities.ClientRequestHeader && threadID != "" { | |
| 479 | + | httpReq.Header.Set("x-client-request-id", threadID) | |
| 413 | 480 | } | |
| 414 | 481 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | |
| 415 | 482 | } else { | |
| @@ -450,10 +517,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 450 | 517 | // event vocabulary, so keeping the state machine here prevents the transports | |
| 451 | 518 | // from drifting in tool-call, reasoning, and usage handling. | |
| 452 | 519 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | |
| 453 | - | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed) | |
| 520 | + | var observe func(string, Message) | |
| 521 | + | if completed != nil { | |
| 522 | + | observe = func(responseID string, _ Message) { completed(responseID) } | |
| 523 | + | } | |
| 524 | + | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe) | |
| 454 | 525 | } | |
| 455 | 526 | ||
| 456 | - | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) { | |
| 527 | + | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) { | |
| 457 | 528 | model, _ := c.findModel(req.Model) | |
| 458 | 529 | providerName := c.providerName | |
| 459 | 530 | if providerName == "" { | |
| @@ -659,11 +730,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 659 | 730 | Response struct { | |
| 660 | 731 | ID string `json:"id"` | |
| 661 | 732 | Usage struct { | |
| 662 | - | InputTokens int `json:"input_tokens"` | |
| 663 | - | OutputTokens int `json:"output_tokens"` | |
| 664 | - | InputTokensDetails struct { | |
| 665 | - | CachedTokens int `json:"cached_tokens"` | |
| 666 | - | } `json:"input_tokens_details"` | |
| 733 | + | InputTokens int `json:"input_tokens"` | |
| 734 | + | OutputTokens int `json:"output_tokens"` | |
| 735 | + | InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"` | |
| 667 | 736 | OutputTokensDetails *struct { | |
| 668 | 737 | ReasoningTokens int `json:"reasoning_tokens"` | |
| 669 | 738 | } `json:"output_tokens_details"` | |
| @@ -672,12 +741,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 672 | 741 | } `json:"response"` | |
| 673 | 742 | } | |
| 674 | 743 | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 675 | - | usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens | |
| 676 | - | if usage.InputTokens < 0 { | |
| 677 | - | usage.InputTokens = p.Response.Usage.InputTokens | |
| 678 | - | } | |
| 679 | - | usage.OutputTokens = p.Response.Usage.OutputTokens | |
| 680 | - | usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens | |
| 744 | + | usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails) | |
| 681 | 745 | if details := p.Response.Usage.OutputTokensDetails; details != nil { | |
| 682 | 746 | usage.ReasoningTokens = details.ReasoningTokens | |
| 683 | 747 | usage.ReasoningTokensKnown = true | |
| @@ -696,7 +760,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 696 | 760 | stop = StopEnd | |
| 697 | 761 | } | |
| 698 | 762 | if completed != nil && p.Response.ID != "" { | |
| 699 | - | completed(p.Response.ID) | |
| 763 | + | completed(p.Response.ID, assemble()) | |
| 700 | 764 | } | |
| 701 | 765 | sendDone() | |
| 702 | 766 | return | |
| @@ -51,8 +51,11 @@ type codexClient struct { | |||||
| 51 | // responsesCapabilities records extensions verified for the exact provider | 51 | // responsesCapabilities records extensions verified for the exact provider | ||
| 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | ||
| 53 | type responsesCapabilities struct { | 53 | type responsesCapabilities struct { | ||
| 54 | - | StablePromptCacheKey bool | 54 | + | StablePromptCacheKey bool |
| 55 | - | SessionHeader bool | 55 | + | ExplicitPromptCacheBreakpoint bool |
| 56 | + | SessionHeader bool | |||
| 57 | + | ThreadHeader bool | |||
| 58 | + | ClientRequestHeader bool | |||
| 56 | } | 59 | } | ||
| 57 | 60 | ||||
| 58 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | 61 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | ||
| @@ -78,6 +81,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||||
| 78 | capabilities: responsesCapabilities{ | 81 | capabilities: responsesCapabilities{ | ||
| 79 | StablePromptCacheKey: true, | 82 | StablePromptCacheKey: true, | ||
| 80 | SessionHeader: true, | 83 | SessionHeader: true, | ||
| 84 | + | ThreadHeader: true, | |||
| 85 | + | ClientRequestHeader: true, | |||
| 81 | }, | 86 | }, | ||
| 82 | http: &http.Client{Timeout: 0}, | 87 | http: &http.Client{Timeout: 0}, | ||
| 83 | } | 88 | } | ||
| @@ -88,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" } | |||||
| 88 | // ---- Responses API wire types (subset needed for zut's surface) ---- | 93 | // ---- Responses API wire types (subset needed for zut's surface) ---- | ||
| 89 | 94 | ||||
| 90 | type codexInputText struct { | 95 | type codexInputText struct { | ||
| 91 | - | Type string `json:"type"` // "input_text" | 96 | + | Type string `json:"type"` // "input_text" |
| 92 | - | Text string `json:"text"` | 97 | + | Text string `json:"text"` |
| 98 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |||
| 93 | } | 99 | } | ||
| 94 | 100 | ||||
| 95 | type codexInputImage struct { | 101 | type codexInputImage struct { | ||
| @@ -104,6 +110,14 @@ type codexOutputText struct { | |||||
| 104 | Annotations []any `json:"annotations"` | 110 | Annotations []any `json:"annotations"` | ||
| 105 | } | 111 | } | ||
| 106 | 112 | ||||
| 113 | + | type codexPromptCacheOptions struct { | |||
| 114 | + | Mode string `json:"mode"` | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | type codexPromptCacheBreakpoint struct { | |||
| 118 | + | Mode string `json:"mode"` | |||
| 119 | + | } | |||
| 120 | + | ||||
| 107 | type codexInputMessage struct { | 121 | type codexInputMessage struct { | ||
| 108 | Type string `json:"type,omitempty"` // "message" (optional for input) | 122 | Type string `json:"type,omitempty"` // "message" (optional for input) | ||
| 109 | Role string `json:"role"` | 123 | Role string `json:"role"` | ||
| @@ -162,19 +176,20 @@ type codexReasoningConfig struct { | |||||
| 162 | } | 176 | } | ||
| 163 | 177 | ||||
| 164 | type codexRequest struct { | 178 | type codexRequest struct { | ||
| 165 | - | Model string `json:"model"` | 179 | + | Model string `json:"model"` |
| 166 | - | Store bool `json:"store"` | 180 | + | Store bool `json:"store"` |
| 167 | - | Stream bool `json:"stream,omitempty"` | 181 | + | Stream bool `json:"stream,omitempty"` |
| 168 | - | Instructions string `json:"instructions,omitempty"` | 182 | + | Instructions string `json:"instructions,omitempty"` |
| 169 | - | Input []any `json:"input"` | 183 | + | Input []any `json:"input"` |
| 170 | - | Tools []codexTool `json:"tools,omitempty"` | 184 | + | Tools []codexTool `json:"tools,omitempty"` |
| 171 | - | ToolChoice string `json:"tool_choice,omitempty"` | 185 | + | ToolChoice string `json:"tool_choice,omitempty"` |
| 172 | - | ParallelToolCalls bool `json:"parallel_tool_calls"` | 186 | + | ParallelToolCalls bool `json:"parallel_tool_calls"` |
| 173 | - | Include []string `json:"include,omitempty"` | 187 | + | Include []string `json:"include,omitempty"` |
| 174 | - | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | 188 | + | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` |
| 175 | - | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | 189 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` |
| 176 | - | ServiceTier string `json:"service_tier,omitempty"` | 190 | + | PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"` |
| 177 | - | PreviousResponseID string `json:"previous_response_id,omitempty"` | 191 | + | ServiceTier string `json:"service_tier,omitempty"` |
| 192 | + | PreviousResponseID string `json:"previous_response_id,omitempty"` | |||
| 178 | } | 193 | } | ||
| 179 | 194 | ||||
| 180 | // ---- Request building ---- | 195 | // ---- Request building ---- | ||
| @@ -191,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||||
| 191 | return FindModel("openai", id) | 206 | return FindModel("openai", id) | ||
| 192 | } | 207 | } | ||
| 193 | 208 | ||||
| 209 | + | func isGPT56Model(model string) bool { | |||
| 210 | + | return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 194 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | 213 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | ||
| 195 | providerName := c.providerName | 214 | providerName := c.providerName | ||
| 196 | if providerName == "" { | 215 | if providerName == "" { | ||
| @@ -216,6 +235,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 216 | if req.FastMode { | 235 | if req.FastMode { | ||
| 217 | body.ServiceTier = fastModeServiceTier | 236 | body.ServiceTier = fastModeServiceTier | ||
| 218 | } | 237 | } | ||
| 238 | + | if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) { | |||
| 239 | + | // Keep implicit mode so OpenAI can retain later conversation | |||
| 240 | + | // checkpoints as well as this stable instruction/tool boundary. | |||
| 241 | + | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | |||
| 242 | + | body.Input = append(body.Input, codexInputMessage{ | |||
| 243 | + | Role: "developer", | |||
| 244 | + | Content: []any{codexInputText{ | |||
| 245 | + | Type: "input_text", | |||
| 246 | + | Text: "[Stable prompt boundary]", | |||
| 247 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"}, | |||
| 248 | + | }}, | |||
| 249 | + | }) | |||
| 250 | + | } | |||
| 219 | if m.Reasoning { | 251 | if m.Reasoning { | ||
| 220 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | 252 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | ||
| 221 | if hasReasoningLevelOverride(m, req.Reasoning) { | 253 | if hasReasoningLevelOverride(m, req.Reasoning) { | ||
| @@ -246,7 +278,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 246 | req.Messages = RepairOrphanedToolResults(req.Messages) | 278 | req.Messages = RepairOrphanedToolResults(req.Messages) | ||
| 247 | for _, msg := range req.Messages { | 279 | for _, msg := range req.Messages { | ||
| 248 | switch msg.Role { | 280 | switch msg.Role { | ||
| 249 | - | case RoleUser: | 281 | + | case RoleDeveloper, RoleUser: |
| 250 | content := []any{} | 282 | content := []any{} | ||
| 251 | for _, c := range msg.Content { | 283 | for _, c := range msg.Content { | ||
| 252 | switch v := c.(type) { | 284 | switch v := c.(type) { | ||
| @@ -262,7 +294,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 262 | if len(content) == 0 { | 294 | if len(content) == 0 { | ||
| 263 | continue | 295 | continue | ||
| 264 | } | 296 | } | ||
| 265 | - | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | 297 | + | role := "user" |
| 298 | + | if msg.Role == RoleDeveloper { | |||
| 299 | + | role = "developer" | |||
| 300 | + | } | |||
| 301 | + | body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) | |||
| 266 | case RoleAssistant: | 302 | case RoleAssistant: | ||
| 267 | // Emit one output_message per text block, one function_call per | 303 | // Emit one output_message per text block, one function_call per | ||
| 268 | // tool call, and one reasoning item per ReasoningBlock, | 304 | // tool call, and one reasoning item per ReasoningBlock, | ||
| @@ -363,6 +399,29 @@ func splitCallID(id string) (string, string) { | |||||
| 363 | return id, "" | 399 | return id, "" | ||
| 364 | } | 400 | } | ||
| 365 | 401 | ||||
| 402 | + | const minOpenAIPromptCacheTokens = 1024 | |||
| 403 | + | ||||
| 404 | + | func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics { | |||
| 405 | + | diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation} | |||
| 406 | + | if wire == nil || wire.PromptCacheOptions == nil { | |||
| 407 | + | return diagnostics | |||
| 408 | + | } | |||
| 409 | + | diagnostics.Mode = wire.PromptCacheOptions.Mode | |||
| 410 | + | diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens | |||
| 411 | + | return diagnostics | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // estimatedOpenAIPromptTokens supplies a deliberately conservative local | |||
| 415 | + | // eligibility signal. OpenAI makes the final tokenization and cache decision; | |||
| 416 | + | // this estimate never leaves the process and is not persisted in diagnostics. | |||
| 417 | + | func estimatedOpenAIPromptTokens(wire *codexRequest) int { | |||
| 418 | + | encoded, err := json.Marshal(wire) | |||
| 419 | + | if err != nil { | |||
| 420 | + | return 0 | |||
| 421 | + | } | |||
| 422 | + | return len(encoded) / 4 | |||
| 423 | + | } | |||
| 424 | + | ||||
| 366 | func usesCodexCLIRouting(model string) bool { | 425 | func usesCodexCLIRouting(model string) bool { | ||
| 367 | switch model { | 426 | switch model { | ||
| 368 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | 427 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | ||
| @@ -379,13 +438,15 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 379 | if err != nil { | 438 | if err != nil { | ||
| 380 | return nil, err | 439 | return nil, err | ||
| 381 | } | 440 | } | ||
| 441 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full")) | |||
| 382 | if c.modelName != nil { | 442 | if c.modelName != nil { | ||
| 383 | wire.Model = c.modelName(wire.Model) | 443 | wire.Model = c.modelName(wire.Model) | ||
| 384 | } | 444 | } | ||
| 385 | useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) | 445 | useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) | ||
| 386 | - | logicalSessionID := strings.TrimSpace(req.Context.SessionID) | 446 | + | cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) |
| 387 | - | if c.capabilities.StablePromptCacheKey && logicalSessionID != "" { | 447 | + | threadID := strings.TrimSpace(req.Context.ThreadID) |
| 388 | - | wire.PromptCacheKey = logicalSessionID | 448 | + | if c.capabilities.StablePromptCacheKey && cacheSessionID != "" { |
| 449 | + | wire.PromptCacheKey = cacheSessionID | |||
| 389 | } | 450 | } | ||
| 390 | body, err := json.Marshal(wire) | 451 | body, err := json.Marshal(wire) | ||
| 391 | if err != nil { | 452 | if err != nil { | ||
| @@ -408,8 +469,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 408 | // identities are load-shed with "servers are currently | 469 | // identities are load-shed with "servers are currently | ||
| 409 | // overloaded" errors even when capacity is fine. | 470 | // overloaded" errors even when capacity is fine. | ||
| 410 | httpReq.Header.Set("originator", "codex_cli_rs") | 471 | httpReq.Header.Set("originator", "codex_cli_rs") | ||
| 411 | - | if c.capabilities.SessionHeader && logicalSessionID != "" { | 472 | + | if c.capabilities.SessionHeader && cacheSessionID != "" { |
| 412 | - | httpReq.Header.Set("session-id", logicalSessionID) | 473 | + | httpReq.Header.Set("session-id", cacheSessionID) |
| 474 | + | } | |||
| 475 | + | if c.capabilities.ThreadHeader && threadID != "" { | |||
| 476 | + | httpReq.Header.Set("thread-id", threadID) | |||
| 477 | + | } | |||
| 478 | + | if c.capabilities.ClientRequestHeader && threadID != "" { | |||
| 479 | + | httpReq.Header.Set("x-client-request-id", threadID) | |||
| 413 | } | 480 | } | ||
| 414 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | 481 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | ||
| 415 | } else { | 482 | } else { | ||
| @@ -450,10 +517,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||||
| 450 | // event vocabulary, so keeping the state machine here prevents the transports | 517 | // event vocabulary, so keeping the state machine here prevents the transports | ||
| 451 | // from drifting in tool-call, reasoning, and usage handling. | 518 | // from drifting in tool-call, reasoning, and usage handling. | ||
| 452 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | 519 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | ||
| 453 | - | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed) | 520 | + | var observe func(string, Message) |
| 521 | + | if completed != nil { | |||
| 522 | + | observe = func(responseID string, _ Message) { completed(responseID) } | |||
| 523 | + | } | |||
| 524 | + | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe) | |||
| 454 | } | 525 | } | ||
| 455 | 526 | ||||
| 456 | - | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) { | 527 | + | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) { |
| 457 | model, _ := c.findModel(req.Model) | 528 | model, _ := c.findModel(req.Model) | ||
| 458 | providerName := c.providerName | 529 | providerName := c.providerName | ||
| 459 | if providerName == "" { | 530 | if providerName == "" { | ||
| @@ -659,11 +730,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 659 | Response struct { | 730 | Response struct { | ||
| 660 | ID string `json:"id"` | 731 | ID string `json:"id"` | ||
| 661 | Usage struct { | 732 | Usage struct { | ||
| 662 | - | InputTokens int `json:"input_tokens"` | 733 | + | InputTokens int `json:"input_tokens"` |
| 663 | - | OutputTokens int `json:"output_tokens"` | 734 | + | OutputTokens int `json:"output_tokens"` |
| 664 | - | InputTokensDetails struct { | 735 | + | InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"` |
| 665 | - | CachedTokens int `json:"cached_tokens"` | |||
| 666 | - | } `json:"input_tokens_details"` | |||
| 667 | OutputTokensDetails *struct { | 736 | OutputTokensDetails *struct { | ||
| 668 | ReasoningTokens int `json:"reasoning_tokens"` | 737 | ReasoningTokens int `json:"reasoning_tokens"` | ||
| 669 | } `json:"output_tokens_details"` | 738 | } `json:"output_tokens_details"` | ||
| @@ -672,12 +741,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 672 | } `json:"response"` | 741 | } `json:"response"` | ||
| 673 | } | 742 | } | ||
| 674 | _ = json.Unmarshal([]byte(ev.Data), &p) | 743 | _ = json.Unmarshal([]byte(ev.Data), &p) | ||
| 675 | - | usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens | 744 | + | usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails) |
| 676 | - | if usage.InputTokens < 0 { | |||
| 677 | - | usage.InputTokens = p.Response.Usage.InputTokens | |||
| 678 | - | } | |||
| 679 | - | usage.OutputTokens = p.Response.Usage.OutputTokens | |||
| 680 | - | usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens | |||
| 681 | if details := p.Response.Usage.OutputTokensDetails; details != nil { | 745 | if details := p.Response.Usage.OutputTokensDetails; details != nil { | ||
| 682 | usage.ReasoningTokens = details.ReasoningTokens | 746 | usage.ReasoningTokens = details.ReasoningTokens | ||
| 683 | usage.ReasoningTokensKnown = true | 747 | usage.ReasoningTokensKnown = true | ||
| @@ -696,7 +760,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 696 | stop = StopEnd | 760 | stop = StopEnd | ||
| 697 | } | 761 | } | ||
| 698 | if completed != nil && p.Response.ID != "" { | 762 | if completed != nil && p.Response.ID != "" { | ||
| 699 | - | completed(p.Response.ID) | 763 | + | completed(p.Response.ID, assemble()) |
| 700 | } | 764 | } | ||
| 701 | sendDone() | 765 | sendDone() | ||
| 702 | return | 766 | return | ||
M packages/provider/openai_codex_test.go +48 -12
| @@ -13,6 +13,23 @@ import ( | |||
| 13 | 13 | "golang.org/x/net/websocket" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) { | |
| 17 | + | newWire := func(text string) *codexRequest { | |
| 18 | + | return &codexRequest{ | |
| 19 | + | PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"}, | |
| 20 | + | Input: []any{codexInputMessage{Role: "user", Content: []any{ | |
| 21 | + | codexInputText{Type: "input_text", Text: text}, | |
| 22 | + | }}}, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible { | |
| 26 | + | t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics) | |
| 27 | + | } | |
| 28 | + | if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible { | |
| 29 | + | t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 16 | 33 | // An image-only tool result must not serialize to an empty | |
| 17 | 34 | // function_call_output (the Responses API may reject it) and a | |
| 18 | 35 | // following user-message image must serialize as input_image so the | |
| @@ -92,7 +109,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 92 | 109 | ||
| 93 | 110 | events, err := c.Stream(context.Background(), Request{ | |
| 94 | 111 | Model: "gpt-5.6-terra", | |
| 95 | - | Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"}, | |
| 112 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |
| 96 | 113 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 97 | 114 | }) | |
| 98 | 115 | if err != nil { | |
| @@ -110,11 +127,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 110 | 127 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |
| 111 | 128 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 112 | 129 | } | |
| 113 | - | if gotBody.PromptCacheKey != "logical-session-1" { | |
| 114 | - | t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey) | |
| 130 | + | if gotBody.PromptCacheKey != "cache-root-1" { | |
| 131 | + | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) | |
| 115 | 132 | } | |
| 116 | - | if gotReq.Header.Get("session-id") != "logical-session-1" { | |
| 117 | - | t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id")) | |
| 133 | + | if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" { | |
| 134 | + | t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) | |
| 118 | 135 | } | |
| 119 | 136 | } | |
| 120 | 137 | ||
| @@ -187,7 +204,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 187 | 204 | ||
| 188 | 205 | events, err := ws.Stream(context.Background(), Request{ | |
| 189 | 206 | Model: "gpt-5.6-sol", | |
| 190 | - | Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"}, | |
| 207 | + | Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, | |
| 191 | 208 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 192 | 209 | }) | |
| 193 | 210 | if err != nil { | |
| @@ -202,8 +219,27 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 202 | 219 | if gotReq.Header.Get("session-id") != "" { | |
| 203 | 220 | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |
| 204 | 221 | } | |
| 205 | - | if gotBody.PromptCacheKey != "logical-session-2" { | |
| 206 | - | t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey) | |
| 222 | + | if gotBody.PromptCacheKey != "cache-root-2" { | |
| 223 | + | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) | |
| 224 | + | } | |
| 225 | + | if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 { | |
| 226 | + | t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input) | |
| 227 | + | } | |
| 228 | + | boundary, ok := gotBody.Input[0].(map[string]any) | |
| 229 | + | if !ok || boundary["role"] != "developer" { | |
| 230 | + | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | |
| 231 | + | } | |
| 232 | + | content, ok := boundary["content"].([]any) | |
| 233 | + | if !ok || len(content) != 1 { | |
| 234 | + | t.Fatalf("stable cache boundary content = %#v", boundary) | |
| 235 | + | } | |
| 236 | + | text, ok := content[0].(map[string]any) | |
| 237 | + | if !ok || text["type"] != "input_text" { | |
| 238 | + | t.Fatalf("stable cache text block = %#v", content) | |
| 239 | + | } | |
| 240 | + | breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any) | |
| 241 | + | if !ok || breakpoint["mode"] != "explicit" { | |
| 242 | + | t.Fatalf("stable cache breakpoint = %#v", text) | |
| 207 | 243 | } | |
| 208 | 244 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | |
| 209 | 245 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | |
| @@ -220,7 +256,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T | |||
| 220 | 256 | } | |
| 221 | 257 | return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil | |
| 222 | 258 | }) | |
| 223 | - | events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}}) | |
| 259 | + | events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}}) | |
| 224 | 260 | if err != nil { | |
| 225 | 261 | t.Fatal(err) | |
| 226 | 262 | } | |
| @@ -272,7 +308,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||
| 272 | 308 | ||
| 273 | 309 | events, err := c.Stream(context.Background(), Request{ | |
| 274 | 310 | Model: "gpt-5.6-sol", | |
| 275 | - | Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"}, | |
| 311 | + | Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, | |
| 276 | 312 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 277 | 313 | }) | |
| 278 | 314 | if err != nil { | |
| @@ -290,8 +326,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||
| 290 | 326 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |
| 291 | 327 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 292 | 328 | } | |
| 293 | - | if gotReq.Header.Get("session-id") != "logical-session-2" { | |
| 294 | - | t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id")) | |
| 329 | + | if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" { | |
| 330 | + | t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) | |
| 295 | 331 | } | |
| 296 | 332 | if !strings.Contains(body.String(), "prompt_cache_key") { | |
| 297 | 333 | t.Fatalf("request missing prompt_cache_key: %s", body.String()) | |
| @@ -13,6 +13,23 @@ import ( | |||||
| 13 | "golang.org/x/net/websocket" | 13 | "golang.org/x/net/websocket" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) { | |||
| 17 | + | newWire := func(text string) *codexRequest { | |||
| 18 | + | return &codexRequest{ | |||
| 19 | + | PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"}, | |||
| 20 | + | Input: []any{codexInputMessage{Role: "user", Content: []any{ | |||
| 21 | + | codexInputText{Type: "input_text", Text: text}, | |||
| 22 | + | }}}, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible { | |||
| 26 | + | t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics) | |||
| 27 | + | } | |||
| 28 | + | if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible { | |||
| 29 | + | t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 16 | // An image-only tool result must not serialize to an empty | 33 | // An image-only tool result must not serialize to an empty | ||
| 17 | // function_call_output (the Responses API may reject it) and a | 34 | // function_call_output (the Responses API may reject it) and a | ||
| 18 | // following user-message image must serialize as input_image so the | 35 | // following user-message image must serialize as input_image so the | ||
| @@ -92,7 +109,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||||
| 92 | 109 | ||||
| 93 | events, err := c.Stream(context.Background(), Request{ | 110 | events, err := c.Stream(context.Background(), Request{ | ||
| 94 | Model: "gpt-5.6-terra", | 111 | Model: "gpt-5.6-terra", | ||
| 95 | - | Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"}, | 112 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, |
| 96 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | 113 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | ||
| 97 | }) | 114 | }) | ||
| 98 | if err != nil { | 115 | if err != nil { | ||
| @@ -110,11 +127,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||||
| 110 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | 127 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | ||
| 111 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | 128 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | ||
| 112 | } | 129 | } | ||
| 113 | - | if gotBody.PromptCacheKey != "logical-session-1" { | 130 | + | if gotBody.PromptCacheKey != "cache-root-1" { |
| 114 | - | t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey) | 131 | + | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) |
| 115 | } | 132 | } | ||
| 116 | - | if gotReq.Header.Get("session-id") != "logical-session-1" { | 133 | + | if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" { |
| 117 | - | t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id")) | 134 | + | t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) |
| 118 | } | 135 | } | ||
| 119 | } | 136 | } | ||
| 120 | 137 | ||||
| @@ -187,7 +204,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||||
| 187 | 204 | ||||
| 188 | events, err := ws.Stream(context.Background(), Request{ | 205 | events, err := ws.Stream(context.Background(), Request{ | ||
| 189 | Model: "gpt-5.6-sol", | 206 | Model: "gpt-5.6-sol", | ||
| 190 | - | Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"}, | 207 | + | Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, |
| 191 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | 208 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | ||
| 192 | }) | 209 | }) | ||
| 193 | if err != nil { | 210 | if err != nil { | ||
| @@ -202,8 +219,27 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||||
| 202 | if gotReq.Header.Get("session-id") != "" { | 219 | if gotReq.Header.Get("session-id") != "" { | ||
| 203 | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | 220 | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | ||
| 204 | } | 221 | } | ||
| 205 | - | if gotBody.PromptCacheKey != "logical-session-2" { | 222 | + | if gotBody.PromptCacheKey != "cache-root-2" { |
| 206 | - | t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey) | 223 | + | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) |
| 224 | + | } | |||
| 225 | + | if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 { | |||
| 226 | + | t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input) | |||
| 227 | + | } | |||
| 228 | + | boundary, ok := gotBody.Input[0].(map[string]any) | |||
| 229 | + | if !ok || boundary["role"] != "developer" { | |||
| 230 | + | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | |||
| 231 | + | } | |||
| 232 | + | content, ok := boundary["content"].([]any) | |||
| 233 | + | if !ok || len(content) != 1 { | |||
| 234 | + | t.Fatalf("stable cache boundary content = %#v", boundary) | |||
| 235 | + | } | |||
| 236 | + | text, ok := content[0].(map[string]any) | |||
| 237 | + | if !ok || text["type"] != "input_text" { | |||
| 238 | + | t.Fatalf("stable cache text block = %#v", content) | |||
| 239 | + | } | |||
| 240 | + | breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any) | |||
| 241 | + | if !ok || breakpoint["mode"] != "explicit" { | |||
| 242 | + | t.Fatalf("stable cache breakpoint = %#v", text) | |||
| 207 | } | 243 | } | ||
| 208 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | 244 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | ||
| 209 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | 245 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | ||
| @@ -220,7 +256,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T | |||||
| 220 | } | 256 | } | ||
| 221 | return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil | 257 | return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil | ||
| 222 | }) | 258 | }) | ||
| 223 | - | events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}}) | 259 | + | events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}}) |
| 224 | if err != nil { | 260 | if err != nil { | ||
| 225 | t.Fatal(err) | 261 | t.Fatal(err) | ||
| 226 | } | 262 | } | ||
| @@ -272,7 +308,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||||
| 272 | 308 | ||||
| 273 | events, err := c.Stream(context.Background(), Request{ | 309 | events, err := c.Stream(context.Background(), Request{ | ||
| 274 | Model: "gpt-5.6-sol", | 310 | Model: "gpt-5.6-sol", | ||
| 275 | - | Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"}, | 311 | + | Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, |
| 276 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | 312 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | ||
| 277 | }) | 313 | }) | ||
| 278 | if err != nil { | 314 | if err != nil { | ||
| @@ -290,8 +326,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||||
| 290 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | 326 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | ||
| 291 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | 327 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | ||
| 292 | } | 328 | } | ||
| 293 | - | if gotReq.Header.Get("session-id") != "logical-session-2" { | 329 | + | if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" { |
| 294 | - | t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id")) | 330 | + | t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) |
| 295 | } | 331 | } | ||
| 296 | if !strings.Contains(body.String(), "prompt_cache_key") { | 332 | if !strings.Contains(body.String(), "prompt_cache_key") { | ||
| 297 | t.Fatalf("request missing prompt_cache_key: %s", body.String()) | 333 | t.Fatalf("request missing prompt_cache_key: %s", body.String()) | ||
M packages/provider/openai_responses.go +3 -2
| @@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||
| 78 | 78 | errorLabel: "openai", | |
| 79 | 79 | providerName: name, | |
| 80 | 80 | capabilities: responsesCapabilities{ | |
| 81 | - | // Only the official OpenAI provider declares this extension. | |
| 81 | + | // Only the official OpenAI provider declares these extensions. | |
| 82 | 82 | // A Responses-shaped custom endpoint must opt in through its | |
| 83 | 83 | // own client rather than inheriting OpenAI's cache contract. | |
| 84 | - | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 84 | + | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 85 | + | ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 85 | 86 | }, | |
| 86 | 87 | http: httpClient, | |
| 87 | 88 | } | |
| @@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||||
| 78 | errorLabel: "openai", | 78 | errorLabel: "openai", | ||
| 79 | providerName: name, | 79 | providerName: name, | ||
| 80 | capabilities: responsesCapabilities{ | 80 | capabilities: responsesCapabilities{ | ||
| 81 | - | // Only the official OpenAI provider declares this extension. | 81 | + | // Only the official OpenAI provider declares these extensions. |
| 82 | // A Responses-shaped custom endpoint must opt in through its | 82 | // A Responses-shaped custom endpoint must opt in through its | ||
| 83 | // own client rather than inheriting OpenAI's cache contract. | 83 | // own client rather than inheriting OpenAI's cache contract. | ||
| 84 | - | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | 84 | + | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), |
| 85 | + | ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL), | |||
| 85 | }, | 86 | }, | ||
| 86 | http: httpClient, | 87 | http: httpClient, | ||
| 87 | } | 88 | } | ||
M packages/provider/openai_responses_websocket.go +94 -26
| @@ -2,7 +2,9 @@ package provider | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/sha256" | |
| 5 | 6 | "crypto/tls" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "encoding/json" | |
| 7 | 9 | "fmt" | |
| 8 | 10 | "net/http" | |
| @@ -10,6 +12,7 @@ import ( | |||
| 10 | 12 | "reflect" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| 15 | + | "time" | |
| 13 | 16 | ||
| 14 | 17 | "golang.org/x/net/websocket" | |
| 15 | 18 | ) | |
| @@ -36,7 +39,11 @@ type responsesWebSocketSession struct { | |||
| 36 | 39 | raw chan sseEvent | |
| 37 | 40 | readerDone chan struct{} | |
| 38 | 41 | ||
| 39 | - | lastRequest []Message | |
| 42 | + | // lastRequest is the complete request accepted by the server. Continuation | |
| 43 | + | // must compare every response-context setting, not merely its transcript. | |
| 44 | + | lastRequest Request | |
| 45 | + | lastBaseline string | |
| 46 | + | lastOutput Message | |
| 40 | 47 | lastResponseID string | |
| 41 | 48 | } | |
| 42 | 49 | ||
| @@ -152,15 +159,16 @@ func responsesWebSocketURL(endpoint string) (string, error) { | |||
| 152 | 159 | } | |
| 153 | 160 | ||
| 154 | 161 | func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 155 | - | // WebSocket continuation state is keyed by the durable core session ID. | |
| 156 | - | // Requests without that identity retain HTTP/SSE semantics rather than | |
| 157 | - | // accidentally pooling unrelated conversations. | |
| 158 | - | sessionID := strings.TrimSpace(req.Context.SessionID) | |
| 159 | - | if sessionID == "" { | |
| 162 | + | // WebSocket continuation state is isolated per conversation thread. Cache | |
| 163 | + | // affinity may be shared by root and resident children, but their server | |
| 164 | + | // response chains must never be pooled. | |
| 165 | + | threadID := strings.TrimSpace(req.Context.ThreadID) | |
| 166 | + | if threadID == "" { | |
| 160 | 167 | return c.http.Stream(ctx, req) | |
| 161 | 168 | } | |
| 169 | + | cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) | |
| 162 | 170 | ||
| 163 | - | session := c.session(sessionID) | |
| 171 | + | session := c.session(threadID) | |
| 164 | 172 | select { | |
| 165 | 173 | case <-ctx.Done(): | |
| 166 | 174 | return nil, ctx.Err() | |
| @@ -174,8 +182,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 174 | 182 | } | |
| 175 | 183 | }() | |
| 176 | 184 | ||
| 177 | - | streamReq, previousResponseID := session.incrementalRequest(req) | |
| 178 | - | payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID) | |
| 185 | + | fullBaseline, err := c.canonicalResponsesBaseline(req, cacheSessionID) | |
| 186 | + | if err != nil { | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline) | |
| 190 | + | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | |
| 179 | 191 | if err != nil { | |
| 180 | 192 | return nil, err | |
| 181 | 193 | } | |
| @@ -183,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 183 | 195 | if !responsesWebSocketHTTPCompatible(c.http.http) { | |
| 184 | 196 | return c.http.Stream(ctx, req) | |
| 185 | 197 | } | |
| 198 | + | if wire, wireErr := c.http.buildRequest(streamReq); wireErr == nil { | |
| 199 | + | continuation := "full" | |
| 200 | + | if previousResponseID != "" { | |
| 201 | + | continuation = "incremental" | |
| 202 | + | } | |
| 203 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation)) | |
| 204 | + | } | |
| 186 | 205 | reportRequestAttempt(req.Lifecycle, 1, 2) | |
| 187 | 206 | if err := session.ensureConnected(ctx, c.dial); err != nil { | |
| 188 | 207 | // A connection that never became usable has no server-side turn. The | |
| @@ -215,7 +234,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 215 | 234 | // HTTP/SSE. That keeps the stable cache key and logical IDs intact | |
| 216 | 235 | // without ever reusing the invalid previous_response_id. | |
| 217 | 236 | session.invalidate() | |
| 218 | - | reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID) | |
| 237 | + | reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID) | |
| 219 | 238 | if err != nil { | |
| 220 | 239 | c.forwardResponsesHTTP(ctx, req, out) | |
| 221 | 240 | return | |
| @@ -239,12 +258,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 239 | 258 | ||
| 240 | 259 | completed := false | |
| 241 | 260 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | |
| 242 | - | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) { | |
| 261 | + | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) { | |
| 243 | 262 | stateMu.Lock() | |
| 244 | 263 | terminal = true | |
| 245 | 264 | stateMu.Unlock() | |
| 246 | 265 | completed = true | |
| 247 | - | session.recordCompleted(req.Messages, responseID) | |
| 266 | + | session.recordCompleted(req, fullBaseline, output, responseID) | |
| 248 | 267 | }) | |
| 249 | 268 | close(finished) | |
| 250 | 269 | if !completed { | |
| @@ -254,7 +273,19 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 254 | 273 | return out, nil | |
| 255 | 274 | } | |
| 256 | 275 | ||
| 257 | - | func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) { | |
| 276 | + | // canonicalResponsesBaseline hashes the complete, encoded WebSocket request | |
| 277 | + | // without a server response ID. The digest lets continuation compare every | |
| 278 | + | // wire-visible setting without retaining prompts, IDs, or credentials. | |
| 279 | + | func (c *responsesWebSocketClient) canonicalResponsesBaseline(req Request, cacheSessionID string) (string, error) { | |
| 280 | + | payload, err := c.websocketPayload(req, cacheSessionID, "") | |
| 281 | + | if err != nil { | |
| 282 | + | return "", err | |
| 283 | + | } | |
| 284 | + | digest := sha256.Sum256(payload) | |
| 285 | + | return hex.EncodeToString(digest[:]), nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) { | |
| 258 | 289 | wire, err := c.http.buildRequest(req) | |
| 259 | 290 | if err != nil { | |
| 260 | 291 | return nil, err | |
| @@ -262,8 +293,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||
| 262 | 293 | if c.http.modelName != nil { | |
| 263 | 294 | wire.Model = c.http.modelName(wire.Model) | |
| 264 | 295 | } | |
| 265 | - | if c.http.capabilities.StablePromptCacheKey { | |
| 266 | - | wire.PromptCacheKey = sessionID | |
| 296 | + | if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" { | |
| 297 | + | wire.PromptCacheKey = cacheSessionID | |
| 267 | 298 | } | |
| 268 | 299 | wire.Stream = false // WebSocket mode does not send HTTP stream controls. | |
| 269 | 300 | wire.PreviousResponseID = previousResponseID | |
| @@ -276,8 +307,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||
| 276 | 307 | // reconnectFull establishes one replacement WebSocket turn after a cached | |
| 277 | 308 | // continuation failed. A fresh connection cannot rely on the previous server | |
| 278 | 309 | // response, so it always sends the complete local transcript. | |
| 279 | - | func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) { | |
| 280 | - | payload, err := c.websocketPayload(req, sessionID, "") | |
| 310 | + | func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) { | |
| 311 | + | payload, err := c.websocketPayload(req, cacheSessionID, "") | |
| 281 | 312 | if err != nil { | |
| 282 | 313 | return nil, nil, sseEvent{}, "", err | |
| 283 | 314 | } | |
| @@ -417,21 +448,52 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req | |||
| 417 | 448 | } | |
| 418 | 449 | } | |
| 419 | 450 | ||
| 420 | - | func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) { | |
| 451 | + | func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSessionID string, canonicalBaseline func(Request, string) (string, error)) (Request, string) { | |
| 421 | 452 | s.mu.Lock() | |
| 422 | 453 | defer s.mu.Unlock() | |
| 423 | - | if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) { | |
| 454 | + | previous := s.lastRequest | |
| 455 | + | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | |
| 456 | + | return req, "" | |
| 457 | + | } | |
| 458 | + | prefix := req | |
| 459 | + | prefix.Messages = append([]Message(nil), req.Messages[:len(previous.Messages)]...) | |
| 460 | + | baseline, err := canonicalBaseline(prefix, cacheSessionID) | |
| 461 | + | if err != nil || baseline != s.lastBaseline { | |
| 424 | 462 | return req, "" | |
| 425 | 463 | } | |
| 426 | - | // The cached response already contains the assistant output from the last | |
| 427 | - | // generation. Send only the new tool results and/or user message. | |
| 428 | - | req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...) | |
| 429 | - | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |
| 464 | + | // The cached response already contains the exact assistant output from the | |
| 465 | + | // last generation. It must be replayed verbatim in the caller transcript | |
| 466 | + | // before we can safely send only the strict new tool-result/user suffix. | |
| 467 | + | fullRequest := req | |
| 468 | + | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | |
| 469 | + | if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |
| 470 | + | if !sameResponsesOutput(req.Messages[0], s.lastOutput) { | |
| 471 | + | return fullRequest, "" | |
| 472 | + | } | |
| 430 | 473 | req.Messages = req.Messages[1:] | |
| 431 | 474 | } | |
| 432 | 475 | return req, s.lastResponseID | |
| 433 | 476 | } | |
| 434 | 477 | ||
| 478 | + | func sameResponsesOutput(current, previous Message) bool { | |
| 479 | + | // Provider message timestamps are local presentation metadata, not server | |
| 480 | + | // response state. All visible content, call IDs, reasoning replay, and | |
| 481 | + | // metadata remain part of the strict baseline. | |
| 482 | + | current.Time = time.Time{} | |
| 483 | + | previous.Time = time.Time{} | |
| 484 | + | return reflect.DeepEqual(current, previous) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func sameResponsesContinuationConfig(previous, current Request) bool { | |
| 488 | + | previous.Messages = nil | |
| 489 | + | previous.Context.TurnID = "" | |
| 490 | + | previous.Lifecycle = nil | |
| 491 | + | current.Messages = nil | |
| 492 | + | current.Context.TurnID = "" | |
| 493 | + | current.Lifecycle = nil | |
| 494 | + | return reflect.DeepEqual(previous, current) | |
| 495 | + | } | |
| 496 | + | ||
| 435 | 497 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | |
| 436 | 498 | s.mu.Lock() | |
| 437 | 499 | if s.conn != nil && s.raw != nil { | |
| @@ -460,10 +522,14 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||
| 460 | 522 | return s.conn, s.raw | |
| 461 | 523 | } | |
| 462 | 524 | ||
| 463 | - | func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) { | |
| 525 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, baseline string, output Message, responseID string) { | |
| 464 | 526 | s.mu.Lock() | |
| 465 | 527 | defer s.mu.Unlock() | |
| 466 | - | s.lastRequest = append(s.lastRequest[:0], messages...) | |
| 528 | + | request.Messages = append([]Message(nil), request.Messages...) | |
| 529 | + | request.Lifecycle = nil | |
| 530 | + | s.lastRequest = request | |
| 531 | + | s.lastBaseline = baseline | |
| 532 | + | s.lastOutput = output | |
| 467 | 533 | s.lastResponseID = responseID | |
| 468 | 534 | } | |
| 469 | 535 | ||
| @@ -492,7 +558,9 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||
| 492 | 558 | s.conn = nil | |
| 493 | 559 | s.raw = nil | |
| 494 | 560 | s.readerDone = nil | |
| 495 | - | s.lastRequest = nil | |
| 561 | + | s.lastRequest = Request{} | |
| 562 | + | s.lastBaseline = "" | |
| 563 | + | s.lastOutput = Message{} | |
| 496 | 564 | s.lastResponseID = "" | |
| 497 | 565 | } | |
| 498 | 566 | ||
| @@ -2,7 +2,9 @@ package provider | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/sha256" | |||
| 5 | "crypto/tls" | 6 | "crypto/tls" | ||
| 7 | + | "encoding/hex" | |||
| 6 | "encoding/json" | 8 | "encoding/json" | ||
| 7 | "fmt" | 9 | "fmt" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| @@ -10,6 +12,7 @@ import ( | |||||
| 10 | "reflect" | 12 | "reflect" | ||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "sync" | 14 | "sync" | ||
| 15 | + | "time" | |||
| 13 | 16 | ||||
| 14 | "golang.org/x/net/websocket" | 17 | "golang.org/x/net/websocket" | ||
| 15 | ) | 18 | ) | ||
| @@ -36,7 +39,11 @@ type responsesWebSocketSession struct { | |||||
| 36 | raw chan sseEvent | 39 | raw chan sseEvent | ||
| 37 | readerDone chan struct{} | 40 | readerDone chan struct{} | ||
| 38 | 41 | ||||
| 39 | - | lastRequest []Message | 42 | + | // lastRequest is the complete request accepted by the server. Continuation |
| 43 | + | // must compare every response-context setting, not merely its transcript. | |||
| 44 | + | lastRequest Request | |||
| 45 | + | lastBaseline string | |||
| 46 | + | lastOutput Message | |||
| 40 | lastResponseID string | 47 | lastResponseID string | ||
| 41 | } | 48 | } | ||
| 42 | 49 | ||||
| @@ -152,15 +159,16 @@ func responsesWebSocketURL(endpoint string) (string, error) { | |||||
| 152 | } | 159 | } | ||
| 153 | 160 | ||||
| 154 | func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 161 | func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 155 | - | // WebSocket continuation state is keyed by the durable core session ID. | 162 | + | // WebSocket continuation state is isolated per conversation thread. Cache |
| 156 | - | // Requests without that identity retain HTTP/SSE semantics rather than | 163 | + | // affinity may be shared by root and resident children, but their server |
| 157 | - | // accidentally pooling unrelated conversations. | 164 | + | // response chains must never be pooled. |
| 158 | - | sessionID := strings.TrimSpace(req.Context.SessionID) | 165 | + | threadID := strings.TrimSpace(req.Context.ThreadID) |
| 159 | - | if sessionID == "" { | 166 | + | if threadID == "" { |
| 160 | return c.http.Stream(ctx, req) | 167 | return c.http.Stream(ctx, req) | ||
| 161 | } | 168 | } | ||
| 169 | + | cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) | |||
| 162 | 170 | ||||
| 163 | - | session := c.session(sessionID) | 171 | + | session := c.session(threadID) |
| 164 | select { | 172 | select { | ||
| 165 | case <-ctx.Done(): | 173 | case <-ctx.Done(): | ||
| 166 | return nil, ctx.Err() | 174 | return nil, ctx.Err() | ||
| @@ -174,8 +182,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 174 | } | 182 | } | ||
| 175 | }() | 183 | }() | ||
| 176 | 184 | ||||
| 177 | - | streamReq, previousResponseID := session.incrementalRequest(req) | 185 | + | fullBaseline, err := c.canonicalResponsesBaseline(req, cacheSessionID) |
| 178 | - | payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID) | 186 | + | if err != nil { |
| 187 | + | return nil, err | |||
| 188 | + | } | |||
| 189 | + | streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline) | |||
| 190 | + | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | |||
| 179 | if err != nil { | 191 | if err != nil { | ||
| 180 | return nil, err | 192 | return nil, err | ||
| 181 | } | 193 | } | ||
| @@ -183,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 183 | if !responsesWebSocketHTTPCompatible(c.http.http) { | 195 | if !responsesWebSocketHTTPCompatible(c.http.http) { | ||
| 184 | return c.http.Stream(ctx, req) | 196 | return c.http.Stream(ctx, req) | ||
| 185 | } | 197 | } | ||
| 198 | + | if wire, wireErr := c.http.buildRequest(streamReq); wireErr == nil { | |||
| 199 | + | continuation := "full" | |||
| 200 | + | if previousResponseID != "" { | |||
| 201 | + | continuation = "incremental" | |||
| 202 | + | } | |||
| 203 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation)) | |||
| 204 | + | } | |||
| 186 | reportRequestAttempt(req.Lifecycle, 1, 2) | 205 | reportRequestAttempt(req.Lifecycle, 1, 2) | ||
| 187 | if err := session.ensureConnected(ctx, c.dial); err != nil { | 206 | if err := session.ensureConnected(ctx, c.dial); err != nil { | ||
| 188 | // A connection that never became usable has no server-side turn. The | 207 | // A connection that never became usable has no server-side turn. The | ||
| @@ -215,7 +234,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 215 | // HTTP/SSE. That keeps the stable cache key and logical IDs intact | 234 | // HTTP/SSE. That keeps the stable cache key and logical IDs intact | ||
| 216 | // without ever reusing the invalid previous_response_id. | 235 | // without ever reusing the invalid previous_response_id. | ||
| 217 | session.invalidate() | 236 | session.invalidate() | ||
| 218 | - | reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID) | 237 | + | reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID) |
| 219 | if err != nil { | 238 | if err != nil { | ||
| 220 | c.forwardResponsesHTTP(ctx, req, out) | 239 | c.forwardResponsesHTTP(ctx, req, out) | ||
| 221 | return | 240 | return | ||
| @@ -239,12 +258,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 239 | 258 | ||||
| 240 | completed := false | 259 | completed := false | ||
| 241 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | 260 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | ||
| 242 | - | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) { | 261 | + | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) { |
| 243 | stateMu.Lock() | 262 | stateMu.Lock() | ||
| 244 | terminal = true | 263 | terminal = true | ||
| 245 | stateMu.Unlock() | 264 | stateMu.Unlock() | ||
| 246 | completed = true | 265 | completed = true | ||
| 247 | - | session.recordCompleted(req.Messages, responseID) | 266 | + | session.recordCompleted(req, fullBaseline, output, responseID) |
| 248 | }) | 267 | }) | ||
| 249 | close(finished) | 268 | close(finished) | ||
| 250 | if !completed { | 269 | if !completed { | ||
| @@ -254,7 +273,19 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 254 | return out, nil | 273 | return out, nil | ||
| 255 | } | 274 | } | ||
| 256 | 275 | ||||
| 257 | - | func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) { | 276 | + | // canonicalResponsesBaseline hashes the complete, encoded WebSocket request |
| 277 | + | // without a server response ID. The digest lets continuation compare every | |||
| 278 | + | // wire-visible setting without retaining prompts, IDs, or credentials. | |||
| 279 | + | func (c *responsesWebSocketClient) canonicalResponsesBaseline(req Request, cacheSessionID string) (string, error) { | |||
| 280 | + | payload, err := c.websocketPayload(req, cacheSessionID, "") | |||
| 281 | + | if err != nil { | |||
| 282 | + | return "", err | |||
| 283 | + | } | |||
| 284 | + | digest := sha256.Sum256(payload) | |||
| 285 | + | return hex.EncodeToString(digest[:]), nil | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) { | |||
| 258 | wire, err := c.http.buildRequest(req) | 289 | wire, err := c.http.buildRequest(req) | ||
| 259 | if err != nil { | 290 | if err != nil { | ||
| 260 | return nil, err | 291 | return nil, err | ||
| @@ -262,8 +293,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||||
| 262 | if c.http.modelName != nil { | 293 | if c.http.modelName != nil { | ||
| 263 | wire.Model = c.http.modelName(wire.Model) | 294 | wire.Model = c.http.modelName(wire.Model) | ||
| 264 | } | 295 | } | ||
| 265 | - | if c.http.capabilities.StablePromptCacheKey { | 296 | + | if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" { |
| 266 | - | wire.PromptCacheKey = sessionID | 297 | + | wire.PromptCacheKey = cacheSessionID |
| 267 | } | 298 | } | ||
| 268 | wire.Stream = false // WebSocket mode does not send HTTP stream controls. | 299 | wire.Stream = false // WebSocket mode does not send HTTP stream controls. | ||
| 269 | wire.PreviousResponseID = previousResponseID | 300 | wire.PreviousResponseID = previousResponseID | ||
| @@ -276,8 +307,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||||
| 276 | // reconnectFull establishes one replacement WebSocket turn after a cached | 307 | // reconnectFull establishes one replacement WebSocket turn after a cached | ||
| 277 | // continuation failed. A fresh connection cannot rely on the previous server | 308 | // continuation failed. A fresh connection cannot rely on the previous server | ||
| 278 | // response, so it always sends the complete local transcript. | 309 | // response, so it always sends the complete local transcript. | ||
| 279 | - | func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) { | 310 | + | func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) { |
| 280 | - | payload, err := c.websocketPayload(req, sessionID, "") | 311 | + | payload, err := c.websocketPayload(req, cacheSessionID, "") |
| 281 | if err != nil { | 312 | if err != nil { | ||
| 282 | return nil, nil, sseEvent{}, "", err | 313 | return nil, nil, sseEvent{}, "", err | ||
| 283 | } | 314 | } | ||
| @@ -417,21 +448,52 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req | |||||
| 417 | } | 448 | } | ||
| 418 | } | 449 | } | ||
| 419 | 450 | ||||
| 420 | - | func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) { | 451 | + | func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSessionID string, canonicalBaseline func(Request, string) (string, error)) (Request, string) { |
| 421 | s.mu.Lock() | 452 | s.mu.Lock() | ||
| 422 | defer s.mu.Unlock() | 453 | defer s.mu.Unlock() | ||
| 423 | - | if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) { | 454 | + | previous := s.lastRequest |
| 455 | + | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | |||
| 456 | + | return req, "" | |||
| 457 | + | } | |||
| 458 | + | prefix := req | |||
| 459 | + | prefix.Messages = append([]Message(nil), req.Messages[:len(previous.Messages)]...) | |||
| 460 | + | baseline, err := canonicalBaseline(prefix, cacheSessionID) | |||
| 461 | + | if err != nil || baseline != s.lastBaseline { | |||
| 424 | return req, "" | 462 | return req, "" | ||
| 425 | } | 463 | } | ||
| 426 | - | // The cached response already contains the assistant output from the last | 464 | + | // The cached response already contains the exact assistant output from the |
| 427 | - | // generation. Send only the new tool results and/or user message. | 465 | + | // last generation. It must be replayed verbatim in the caller transcript |
| 428 | - | req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...) | 466 | + | // before we can safely send only the strict new tool-result/user suffix. |
| 429 | - | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | 467 | + | fullRequest := req |
| 468 | + | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | |||
| 469 | + | if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |||
| 470 | + | if !sameResponsesOutput(req.Messages[0], s.lastOutput) { | |||
| 471 | + | return fullRequest, "" | |||
| 472 | + | } | |||
| 430 | req.Messages = req.Messages[1:] | 473 | req.Messages = req.Messages[1:] | ||
| 431 | } | 474 | } | ||
| 432 | return req, s.lastResponseID | 475 | return req, s.lastResponseID | ||
| 433 | } | 476 | } | ||
| 434 | 477 | ||||
| 478 | + | func sameResponsesOutput(current, previous Message) bool { | |||
| 479 | + | // Provider message timestamps are local presentation metadata, not server | |||
| 480 | + | // response state. All visible content, call IDs, reasoning replay, and | |||
| 481 | + | // metadata remain part of the strict baseline. | |||
| 482 | + | current.Time = time.Time{} | |||
| 483 | + | previous.Time = time.Time{} | |||
| 484 | + | return reflect.DeepEqual(current, previous) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func sameResponsesContinuationConfig(previous, current Request) bool { | |||
| 488 | + | previous.Messages = nil | |||
| 489 | + | previous.Context.TurnID = "" | |||
| 490 | + | previous.Lifecycle = nil | |||
| 491 | + | current.Messages = nil | |||
| 492 | + | current.Context.TurnID = "" | |||
| 493 | + | current.Lifecycle = nil | |||
| 494 | + | return reflect.DeepEqual(previous, current) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 435 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | 497 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | ||
| 436 | s.mu.Lock() | 498 | s.mu.Lock() | ||
| 437 | if s.conn != nil && s.raw != nil { | 499 | if s.conn != nil && s.raw != nil { | ||
| @@ -460,10 +522,14 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||||
| 460 | return s.conn, s.raw | 522 | return s.conn, s.raw | ||
| 461 | } | 523 | } | ||
| 462 | 524 | ||||
| 463 | - | func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) { | 525 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, baseline string, output Message, responseID string) { |
| 464 | s.mu.Lock() | 526 | s.mu.Lock() | ||
| 465 | defer s.mu.Unlock() | 527 | defer s.mu.Unlock() | ||
| 466 | - | s.lastRequest = append(s.lastRequest[:0], messages...) | 528 | + | request.Messages = append([]Message(nil), request.Messages...) |
| 529 | + | request.Lifecycle = nil | |||
| 530 | + | s.lastRequest = request | |||
| 531 | + | s.lastBaseline = baseline | |||
| 532 | + | s.lastOutput = output | |||
| 467 | s.lastResponseID = responseID | 533 | s.lastResponseID = responseID | ||
| 468 | } | 534 | } | ||
| 469 | 535 | ||||
| @@ -492,7 +558,9 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||||
| 492 | s.conn = nil | 558 | s.conn = nil | ||
| 493 | s.raw = nil | 559 | s.raw = nil | ||
| 494 | s.readerDone = nil | 560 | s.readerDone = nil | ||
| 495 | - | s.lastRequest = nil | 561 | + | s.lastRequest = Request{} |
| 562 | + | s.lastBaseline = "" | |||
| 563 | + | s.lastOutput = Message{} | |||
| 496 | s.lastResponseID = "" | 564 | s.lastResponseID = "" | ||
| 497 | } | 565 | } | ||
| 498 | 566 | ||||
M packages/provider/openai_responses_websocket_test.go +120 -9
| @@ -3,8 +3,10 @@ package provider | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "io" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "net/http/httptest" | |
| 9 | + | "reflect" | |
| 8 | 10 | "strings" | |
| 9 | 11 | "sync" | |
| 10 | 12 | "testing" | |
| @@ -80,10 +82,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t | |||
| 80 | 82 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | |
| 81 | 83 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | |
| 82 | 84 | }) | |
| 83 | - | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | |
| 85 | + | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | |
| 84 | 86 | assertCompletedResponse(t, client, first) | |
| 85 | 87 | lifecycle := &recordingRequestLifecycle{} | |
| 86 | - | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | |
| 88 | + | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | |
| 87 | 89 | assertCompletedResponseText(t, client, second, "reconnected") | |
| 88 | 90 | mu.Lock() | |
| 89 | 91 | defer mu.Unlock() | |
| @@ -163,14 +165,15 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 163 | 165 | first := Request{ | |
| 164 | 166 | Model: "gpt-5.6-sol", | |
| 165 | 167 | FastMode: true, | |
| 166 | - | Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"}, | |
| 168 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |
| 167 | 169 | Lifecycle: lifecycle, | |
| 168 | 170 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |
| 169 | 171 | } | |
| 170 | 172 | assertCompletedResponse(t, client, first) | |
| 171 | 173 | second := Request{ | |
| 172 | - | Model: "gpt-5.6-sol", | |
| 173 | - | Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"}, | |
| 174 | + | Model: "gpt-5.6-sol", | |
| 175 | + | FastMode: true, | |
| 176 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 174 | 177 | Messages: append(append([]Message(nil), first.Messages...), | |
| 175 | 178 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | |
| 176 | 179 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| @@ -195,7 +198,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 195 | 198 | if _, ok := requests[0]["stream"]; ok { | |
| 196 | 199 | t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0]) | |
| 197 | 200 | } | |
| 198 | - | if got := requests[0]["prompt_cache_key"]; got != "session-1" { | |
| 201 | + | if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" { | |
| 199 | 202 | t.Fatalf("prompt_cache_key = %#v", got) | |
| 200 | 203 | } | |
| 201 | 204 | if got := requests[0]["service_tier"]; got != fastModeServiceTier { | |
| @@ -222,6 +225,73 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 222 | 225 | } | |
| 223 | 226 | } | |
| 224 | 227 | ||
| 228 | + | func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) { | |
| 229 | + | mappedModel := "gpt-5.6-sol-a" | |
| 230 | + | client := newResponsesWebSocketClient(&codexClient{ | |
| 231 | + | providerName: "openai", | |
| 232 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 233 | + | modelName: func(string) string { return mappedModel }, | |
| 234 | + | }).(*responsesWebSocketClient) | |
| 235 | + | first := Request{ | |
| 236 | + | Model: "gpt-5.6-sol", | |
| 237 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |
| 238 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |
| 239 | + | } | |
| 240 | + | baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID) | |
| 241 | + | if err != nil { | |
| 242 | + | t.Fatal(err) | |
| 243 | + | } | |
| 244 | + | session := &responsesWebSocketSession{} | |
| 245 | + | session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, "resp_1") | |
| 246 | + | ||
| 247 | + | mappedModel = "gpt-5.6-sol-b" | |
| 248 | + | second := Request{ | |
| 249 | + | Model: first.Model, | |
| 250 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 251 | + | Messages: append(append([]Message(nil), first.Messages...), | |
| 252 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | |
| 253 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| 254 | + | ), | |
| 255 | + | } | |
| 256 | + | streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline) | |
| 257 | + | if previousResponseID != "" { | |
| 258 | + | t.Fatalf("previous response ID = %q, want full request after wire baseline change", previousResponseID) | |
| 259 | + | } | |
| 260 | + | if !reflect.DeepEqual(streamReq.Messages, second.Messages) { | |
| 261 | + | t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestResponsesWebSocketAssistantMismatchForcesCompleteTranscript(t *testing.T) { | |
| 266 | + | client := newResponsesWebSocketClient(&codexClient{ | |
| 267 | + | providerName: "openai", capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 268 | + | }).(*responsesWebSocketClient) | |
| 269 | + | first := Request{ | |
| 270 | + | Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |
| 271 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |
| 272 | + | } | |
| 273 | + | baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID) | |
| 274 | + | if err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | session := &responsesWebSocketSession{} | |
| 278 | + | session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "actual"}}}, "resp_1") | |
| 279 | + | second := Request{ | |
| 280 | + | Model: first.Model, Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 281 | + | Messages: append(append([]Message(nil), first.Messages...), | |
| 282 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "modified"}}}, | |
| 283 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| 284 | + | ), | |
| 285 | + | } | |
| 286 | + | streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline) | |
| 287 | + | if previousResponseID != "" { | |
| 288 | + | t.Fatalf("previous response ID = %q, want complete request", previousResponseID) | |
| 289 | + | } | |
| 290 | + | if !reflect.DeepEqual(streamReq.Messages, second.Messages) { | |
| 291 | + | t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 225 | 295 | func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) { | |
| 226 | 296 | closed := make(chan struct{}) | |
| 227 | 297 | var closeOnce sync.Once | |
| @@ -242,7 +312,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) | |||
| 242 | 312 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 243 | 313 | }) | |
| 244 | 314 | ctx, cancel := context.WithCancel(context.Background()) | |
| 245 | - | stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}}) | |
| 315 | + | stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}}) | |
| 246 | 316 | if err != nil { | |
| 247 | 317 | t.Fatal(err) | |
| 248 | 318 | } | |
| @@ -290,9 +360,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin | |||
| 290 | 360 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | |
| 291 | 361 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | |
| 292 | 362 | }) | |
| 293 | - | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | |
| 363 | + | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | |
| 294 | 364 | assertCompletedResponseText(t, client, first, "first") | |
| 295 | - | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | |
| 365 | + | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | |
| 296 | 366 | assertCompletedResponseText(t, client, second, "second") | |
| 297 | 367 | } | |
| 298 | 368 | ||
| @@ -309,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) { | |||
| 309 | 379 | } | |
| 310 | 380 | } | |
| 311 | 381 | ||
| 382 | + | type cacheDiagnosticsLifecycle struct { | |
| 383 | + | diagnostics []CacheDiagnostics | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func (l *cacheDiagnosticsLifecycle) RequestAttempt(int, int) {} | |
| 387 | + | func (l *cacheDiagnosticsLifecycle) RetryScheduled(int, int, time.Duration) {} | |
| 388 | + | ||
| 389 | + | func (l *cacheDiagnosticsLifecycle) CacheDiagnostics(diagnostics CacheDiagnostics) { | |
| 390 | + | l.diagnostics = append(l.diagnostics, diagnostics) | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestResponsesWebSocketUsesHTTPDiagnosticsForIncompatibleTransport(t *testing.T) { | |
| 394 | + | lifecycle := &cacheDiagnosticsLifecycle{} | |
| 395 | + | client := newResponsesWebSocketClient(&codexClient{ | |
| 396 | + | token: "test-token", baseURL: "https://example.test/v1/responses", providerName: "openai", | |
| 397 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 398 | + | http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { | |
| 399 | + | return &http.Response{ | |
| 400 | + | StatusCode: http.StatusOK, | |
| 401 | + | Header: make(http.Header), | |
| 402 | + | Body: io.NopCloser(strings.NewReader(`data: {"type":"response.completed","response":{"status":"completed","usage":{}}} | |
| 403 | + | ||
| 404 | + | `)), | |
| 405 | + | }, nil | |
| 406 | + | })}, | |
| 407 | + | }) | |
| 408 | + | req := Request{ | |
| 409 | + | Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread"}, Lifecycle: lifecycle, | |
| 410 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}}, | |
| 411 | + | } | |
| 412 | + | stream, err := client.Stream(context.Background(), req) | |
| 413 | + | if err != nil { | |
| 414 | + | t.Fatal(err) | |
| 415 | + | } | |
| 416 | + | for range stream { | |
| 417 | + | } | |
| 418 | + | if len(lifecycle.diagnostics) != 1 || lifecycle.diagnostics[0].Transport != "http_sse" { | |
| 419 | + | t.Fatalf("diagnostics = %#v, want only HTTP/SSE diagnostics", lifecycle.diagnostics) | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | ||
| 312 | 423 | func assertCompletedResponse(t *testing.T, client Client, req Request) { | |
| 313 | 424 | assertCompletedResponseText(t, client, req, "ok") | |
| 314 | 425 | } | |
| @@ -3,8 +3,10 @@ package provider | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "io" | |||
| 6 | "net/http" | 7 | "net/http" | ||
| 7 | "net/http/httptest" | 8 | "net/http/httptest" | ||
| 9 | + | "reflect" | |||
| 8 | "strings" | 10 | "strings" | ||
| 9 | "sync" | 11 | "sync" | ||
| 10 | "testing" | 12 | "testing" | ||
| @@ -80,10 +82,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t | |||||
| 80 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | 82 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | ||
| 81 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | 83 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | ||
| 82 | }) | 84 | }) | ||
| 83 | - | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | 85 | + | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} |
| 84 | assertCompletedResponse(t, client, first) | 86 | assertCompletedResponse(t, client, first) | ||
| 85 | lifecycle := &recordingRequestLifecycle{} | 87 | lifecycle := &recordingRequestLifecycle{} | ||
| 86 | - | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | 88 | + | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} |
| 87 | assertCompletedResponseText(t, client, second, "reconnected") | 89 | assertCompletedResponseText(t, client, second, "reconnected") | ||
| 88 | mu.Lock() | 90 | mu.Lock() | ||
| 89 | defer mu.Unlock() | 91 | defer mu.Unlock() | ||
| @@ -163,14 +165,15 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||||
| 163 | first := Request{ | 165 | first := Request{ | ||
| 164 | Model: "gpt-5.6-sol", | 166 | Model: "gpt-5.6-sol", | ||
| 165 | FastMode: true, | 167 | FastMode: true, | ||
| 166 | - | Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"}, | 168 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, |
| 167 | Lifecycle: lifecycle, | 169 | Lifecycle: lifecycle, | ||
| 168 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | 170 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | ||
| 169 | } | 171 | } | ||
| 170 | assertCompletedResponse(t, client, first) | 172 | assertCompletedResponse(t, client, first) | ||
| 171 | second := Request{ | 173 | second := Request{ | ||
| 172 | - | Model: "gpt-5.6-sol", | 174 | + | Model: "gpt-5.6-sol", |
| 173 | - | Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"}, | 175 | + | FastMode: true, |
| 176 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |||
| 174 | Messages: append(append([]Message(nil), first.Messages...), | 177 | Messages: append(append([]Message(nil), first.Messages...), | ||
| 175 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | 178 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | ||
| 176 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | 179 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | ||
| @@ -195,7 +198,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||||
| 195 | if _, ok := requests[0]["stream"]; ok { | 198 | if _, ok := requests[0]["stream"]; ok { | ||
| 196 | t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0]) | 199 | t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0]) | ||
| 197 | } | 200 | } | ||
| 198 | - | if got := requests[0]["prompt_cache_key"]; got != "session-1" { | 201 | + | if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" { |
| 199 | t.Fatalf("prompt_cache_key = %#v", got) | 202 | t.Fatalf("prompt_cache_key = %#v", got) | ||
| 200 | } | 203 | } | ||
| 201 | if got := requests[0]["service_tier"]; got != fastModeServiceTier { | 204 | if got := requests[0]["service_tier"]; got != fastModeServiceTier { | ||
| @@ -222,6 +225,73 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||||
| 222 | } | 225 | } | ||
| 223 | } | 226 | } | ||
| 224 | 227 | ||||
| 228 | + | func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) { | |||
| 229 | + | mappedModel := "gpt-5.6-sol-a" | |||
| 230 | + | client := newResponsesWebSocketClient(&codexClient{ | |||
| 231 | + | providerName: "openai", | |||
| 232 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |||
| 233 | + | modelName: func(string) string { return mappedModel }, | |||
| 234 | + | }).(*responsesWebSocketClient) | |||
| 235 | + | first := Request{ | |||
| 236 | + | Model: "gpt-5.6-sol", | |||
| 237 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |||
| 238 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |||
| 239 | + | } | |||
| 240 | + | baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID) | |||
| 241 | + | if err != nil { | |||
| 242 | + | t.Fatal(err) | |||
| 243 | + | } | |||
| 244 | + | session := &responsesWebSocketSession{} | |||
| 245 | + | session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, "resp_1") | |||
| 246 | + | ||||
| 247 | + | mappedModel = "gpt-5.6-sol-b" | |||
| 248 | + | second := Request{ | |||
| 249 | + | Model: first.Model, | |||
| 250 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |||
| 251 | + | Messages: append(append([]Message(nil), first.Messages...), | |||
| 252 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | |||
| 253 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |||
| 254 | + | ), | |||
| 255 | + | } | |||
| 256 | + | streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline) | |||
| 257 | + | if previousResponseID != "" { | |||
| 258 | + | t.Fatalf("previous response ID = %q, want full request after wire baseline change", previousResponseID) | |||
| 259 | + | } | |||
| 260 | + | if !reflect.DeepEqual(streamReq.Messages, second.Messages) { | |||
| 261 | + | t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestResponsesWebSocketAssistantMismatchForcesCompleteTranscript(t *testing.T) { | |||
| 266 | + | client := newResponsesWebSocketClient(&codexClient{ | |||
| 267 | + | providerName: "openai", capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |||
| 268 | + | }).(*responsesWebSocketClient) | |||
| 269 | + | first := Request{ | |||
| 270 | + | Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, | |||
| 271 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |||
| 272 | + | } | |||
| 273 | + | baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID) | |||
| 274 | + | if err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | session := &responsesWebSocketSession{} | |||
| 278 | + | session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "actual"}}}, "resp_1") | |||
| 279 | + | second := Request{ | |||
| 280 | + | Model: first.Model, Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |||
| 281 | + | Messages: append(append([]Message(nil), first.Messages...), | |||
| 282 | + | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "modified"}}}, | |||
| 283 | + | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |||
| 284 | + | ), | |||
| 285 | + | } | |||
| 286 | + | streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline) | |||
| 287 | + | if previousResponseID != "" { | |||
| 288 | + | t.Fatalf("previous response ID = %q, want complete request", previousResponseID) | |||
| 289 | + | } | |||
| 290 | + | if !reflect.DeepEqual(streamReq.Messages, second.Messages) { | |||
| 291 | + | t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 225 | func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) { | 295 | func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) { | ||
| 226 | closed := make(chan struct{}) | 296 | closed := make(chan struct{}) | ||
| 227 | var closeOnce sync.Once | 297 | var closeOnce sync.Once | ||
| @@ -242,7 +312,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) | |||||
| 242 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | 312 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | ||
| 243 | }) | 313 | }) | ||
| 244 | ctx, cancel := context.WithCancel(context.Background()) | 314 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 245 | - | stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}}) | 315 | + | stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}}) |
| 246 | if err != nil { | 316 | if err != nil { | ||
| 247 | t.Fatal(err) | 317 | t.Fatal(err) | ||
| 248 | } | 318 | } | ||
| @@ -290,9 +360,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin | |||||
| 290 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | 360 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | ||
| 291 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | 361 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{}, | ||
| 292 | }) | 362 | }) | ||
| 293 | - | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} | 363 | + | first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}} |
| 294 | assertCompletedResponseText(t, client, first, "first") | 364 | assertCompletedResponseText(t, client, first, "first") | ||
| 295 | - | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} | 365 | + | second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})} |
| 296 | assertCompletedResponseText(t, client, second, "second") | 366 | assertCompletedResponseText(t, client, second, "second") | ||
| 297 | } | 367 | } | ||
| 298 | 368 | ||||
| @@ -309,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) { | |||||
| 309 | } | 379 | } | ||
| 310 | } | 380 | } | ||
| 311 | 381 | ||||
| 382 | + | type cacheDiagnosticsLifecycle struct { | |||
| 383 | + | diagnostics []CacheDiagnostics | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func (l *cacheDiagnosticsLifecycle) RequestAttempt(int, int) {} | |||
| 387 | + | func (l *cacheDiagnosticsLifecycle) RetryScheduled(int, int, time.Duration) {} | |||
| 388 | + | ||||
| 389 | + | func (l *cacheDiagnosticsLifecycle) CacheDiagnostics(diagnostics CacheDiagnostics) { | |||
| 390 | + | l.diagnostics = append(l.diagnostics, diagnostics) | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func TestResponsesWebSocketUsesHTTPDiagnosticsForIncompatibleTransport(t *testing.T) { | |||
| 394 | + | lifecycle := &cacheDiagnosticsLifecycle{} | |||
| 395 | + | client := newResponsesWebSocketClient(&codexClient{ | |||
| 396 | + | token: "test-token", baseURL: "https://example.test/v1/responses", providerName: "openai", | |||
| 397 | + | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |||
| 398 | + | http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { | |||
| 399 | + | return &http.Response{ | |||
| 400 | + | StatusCode: http.StatusOK, | |||
| 401 | + | Header: make(http.Header), | |||
| 402 | + | Body: io.NopCloser(strings.NewReader(`data: {"type":"response.completed","response":{"status":"completed","usage":{}}} | |||
| 403 | + | ||||
| 404 | + | `)), | |||
| 405 | + | }, nil | |||
| 406 | + | })}, | |||
| 407 | + | }) | |||
| 408 | + | req := Request{ | |||
| 409 | + | Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread"}, Lifecycle: lifecycle, | |||
| 410 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}}, | |||
| 411 | + | } | |||
| 412 | + | stream, err := client.Stream(context.Background(), req) | |||
| 413 | + | if err != nil { | |||
| 414 | + | t.Fatal(err) | |||
| 415 | + | } | |||
| 416 | + | for range stream { | |||
| 417 | + | } | |||
| 418 | + | if len(lifecycle.diagnostics) != 1 || lifecycle.diagnostics[0].Transport != "http_sse" { | |||
| 419 | + | t.Fatalf("diagnostics = %#v, want only HTTP/SSE diagnostics", lifecycle.diagnostics) | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | ||||
| 312 | func assertCompletedResponse(t *testing.T, client Client, req Request) { | 423 | func assertCompletedResponse(t *testing.T, client Client, req Request) { | ||
| 313 | assertCompletedResponseText(t, client, req, "ok") | 424 | assertCompletedResponseText(t, client, req, "ok") | ||
| 314 | } | 425 | } | ||
A packages/provider/openai_usage.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | // openAIInputTokensDetails is shared by the Chat Completions and Responses | |
| 4 | + | // streams. The details object is optional; its presence is the availability | |
| 5 | + | // signal for cache accounting, including a known cache miss. | |
| 6 | + | type openAIInputTokensDetails struct { | |
| 7 | + | CachedTokens int `json:"cached_tokens"` | |
| 8 | + | CacheWriteTokens int `json:"cache_write_tokens"` | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // normalizeOpenAIUsage converts OpenAI's total input count and optional cache | |
| 12 | + | // detail into disjoint usage buckets. Malformed detail must not create a | |
| 13 | + | // negative ordinary-input count or an unsupported cache metric, so it falls | |
| 14 | + | // back to treating the total as ordinary input. | |
| 15 | + | func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage { | |
| 16 | + | if inputTokens < 0 { | |
| 17 | + | inputTokens = 0 | |
| 18 | + | } | |
| 19 | + | if outputTokens < 0 { | |
| 20 | + | outputTokens = 0 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens} | |
| 24 | + | if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 { | |
| 25 | + | return usage | |
| 26 | + | } | |
| 27 | + | if details.CachedTokens+details.CacheWriteTokens > inputTokens { | |
| 28 | + | return usage | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens | |
| 32 | + | usage.CacheReadTokens = details.CachedTokens | |
| 33 | + | usage.CacheWriteTokens = details.CacheWriteTokens | |
| 34 | + | usage.CacheMeasuredPromptTokens = inputTokens | |
| 35 | + | usage.CacheMeasuredReadTokens = details.CachedTokens | |
| 36 | + | return usage | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | // openAIInputTokensDetails is shared by the Chat Completions and Responses | |||
| 4 | + | // streams. The details object is optional; its presence is the availability | |||
| 5 | + | // signal for cache accounting, including a known cache miss. | |||
| 6 | + | type openAIInputTokensDetails struct { | |||
| 7 | + | CachedTokens int `json:"cached_tokens"` | |||
| 8 | + | CacheWriteTokens int `json:"cache_write_tokens"` | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // normalizeOpenAIUsage converts OpenAI's total input count and optional cache | |||
| 12 | + | // detail into disjoint usage buckets. Malformed detail must not create a | |||
| 13 | + | // negative ordinary-input count or an unsupported cache metric, so it falls | |||
| 14 | + | // back to treating the total as ordinary input. | |||
| 15 | + | func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage { | |||
| 16 | + | if inputTokens < 0 { | |||
| 17 | + | inputTokens = 0 | |||
| 18 | + | } | |||
| 19 | + | if outputTokens < 0 { | |||
| 20 | + | outputTokens = 0 | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens} | |||
| 24 | + | if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 { | |||
| 25 | + | return usage | |||
| 26 | + | } | |||
| 27 | + | if details.CachedTokens+details.CacheWriteTokens > inputTokens { | |||
| 28 | + | return usage | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens | |||
| 32 | + | usage.CacheReadTokens = details.CachedTokens | |||
| 33 | + | usage.CacheWriteTokens = details.CacheWriteTokens | |||
| 34 | + | usage.CacheMeasuredPromptTokens = inputTokens | |||
| 35 | + | usage.CacheMeasuredReadTokens = details.CachedTokens | |||
| 36 | + | return usage | |||
| 37 | + | } | |||
M packages/provider/provider.go +60 -26
| @@ -20,6 +20,9 @@ const ( | |||
| 20 | 20 | RoleUser Role = "user" | |
| 21 | 21 | RoleAssistant Role = "assistant" | |
| 22 | 22 | RoleTool Role = "tool" | |
| 23 | + | // RoleDeveloper is host-authored provider context. Core persists it for | |
| 24 | + | // replay but presentation layers must not render it as ordinary chat. | |
| 25 | + | RoleDeveloper Role = "developer" | |
| 23 | 26 | ) | |
| 24 | 27 | ||
| 25 | 28 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | |
| @@ -171,13 +174,19 @@ func activatedToolNames(messages []Message) map[string]bool { | |||
| 171 | 174 | ||
| 172 | 175 | // Usage aggregates token counts and cost for a turn. | |
| 173 | 176 | type Usage struct { | |
| 174 | - | InputTokens int `json:"input_tokens"` | |
| 175 | - | OutputTokens int `json:"output_tokens"` | |
| 176 | - | ReasoningTokens int `json:"reasoning_tokens"` | |
| 177 | - | ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"` | |
| 178 | - | CacheReadTokens int `json:"cache_read_tokens"` | |
| 179 | - | CacheWriteTokens int `json:"cache_write_tokens"` | |
| 180 | - | CostUSD float64 `json:"cost_usd"` | |
| 177 | + | InputTokens int `json:"input_tokens"` | |
| 178 | + | OutputTokens int `json:"output_tokens"` | |
| 179 | + | ReasoningTokens int `json:"reasoning_tokens"` | |
| 180 | + | ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"` | |
| 181 | + | CacheReadTokens int `json:"cache_read_tokens"` | |
| 182 | + | CacheWriteTokens int `json:"cache_write_tokens"` | |
| 183 | + | // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the | |
| 184 | + | // portion of the prompt for which the provider explicitly reported cache | |
| 185 | + | // details. Zero values mean cache details were unavailable, including in | |
| 186 | + | // historical session rows. | |
| 187 | + | CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"` | |
| 188 | + | CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"` | |
| 189 | + | CostUSD float64 `json:"cost_usd"` | |
| 181 | 190 | } | |
| 182 | 191 | ||
| 183 | 192 | // PromptTokens returns all input tokens, regardless of cache disposition. | |
| @@ -185,28 +194,30 @@ func (u Usage) PromptTokens() int { | |||
| 185 | 194 | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | |
| 186 | 195 | } | |
| 187 | 196 | ||
| 188 | - | // CacheHitRatio returns the share of prompt tokens served from cache. The | |
| 189 | - | // boolean is false when the provider reported no cache reads, which avoids | |
| 190 | - | // presenting an unsupported cache metric as a zero-percent hit rate. | |
| 197 | + | // CacheHitRatio returns the share of provider-measured prompt tokens served | |
| 198 | + | // from cache. The boolean is false when cache details were unavailable, which | |
| 199 | + | // keeps historical or unsupported usage distinct from a known zero-percent | |
| 200 | + | // cache hit rate. | |
| 191 | 201 | func (u Usage) CacheHitRatio() (float64, bool) { | |
| 192 | - | total := u.PromptTokens() | |
| 193 | - | if total <= 0 || u.CacheReadTokens <= 0 { | |
| 202 | + | if u.CacheMeasuredPromptTokens <= 0 { | |
| 194 | 203 | return 0, false | |
| 195 | 204 | } | |
| 196 | - | return float64(u.CacheReadTokens) / float64(total), true | |
| 205 | + | return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true | |
| 197 | 206 | } | |
| 198 | 207 | ||
| 199 | 208 | // Add returns u plus v. A reasoning total is known only when both | |
| 200 | 209 | // component usage reports include a separate reasoning-token count. | |
| 201 | 210 | func (u Usage) Add(v Usage) Usage { | |
| 202 | 211 | return Usage{ | |
| 203 | - | InputTokens: u.InputTokens + v.InputTokens, | |
| 204 | - | OutputTokens: u.OutputTokens + v.OutputTokens, | |
| 205 | - | ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens, | |
| 206 | - | ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown, | |
| 207 | - | CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens, | |
| 208 | - | CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, | |
| 209 | - | CostUSD: u.CostUSD + v.CostUSD, | |
| 212 | + | InputTokens: u.InputTokens + v.InputTokens, | |
| 213 | + | OutputTokens: u.OutputTokens + v.OutputTokens, | |
| 214 | + | ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens, | |
| 215 | + | ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown, | |
| 216 | + | CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens, | |
| 217 | + | CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, | |
| 218 | + | CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens, | |
| 219 | + | CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens, | |
| 220 | + | CostUSD: u.CostUSD + v.CostUSD, | |
| 210 | 221 | } | |
| 211 | 222 | } | |
| 212 | 223 | ||
| @@ -290,6 +301,27 @@ type RequestAttemptIDLifecycle interface { | |||
| 290 | 301 | RequestAttemptID(requestID string, attempt, maxAttempts int) | |
| 291 | 302 | } | |
| 292 | 303 | ||
| 304 | + | // CacheDiagnostics contains only allowlisted cache-routing state. It must not | |
| 305 | + | // contain prompt text, durable identities, request bodies, or credentials. | |
| 306 | + | type CacheDiagnostics struct { | |
| 307 | + | Eligible bool | |
| 308 | + | Mode string | |
| 309 | + | Transport string | |
| 310 | + | Continuation string | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // RequestCacheDiagnosticsLifecycle receives sanitized cache-routing state when | |
| 314 | + | // an adapter can determine it. It is optional to preserve existing clients. | |
| 315 | + | type RequestCacheDiagnosticsLifecycle interface { | |
| 316 | + | CacheDiagnostics(CacheDiagnostics) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func ReportCacheDiagnostics(lifecycle RequestLifecycle, diagnostics CacheDiagnostics) { | |
| 320 | + | if withDiagnostics, ok := lifecycle.(RequestCacheDiagnosticsLifecycle); ok { | |
| 321 | + | withDiagnostics.CacheDiagnostics(diagnostics) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 293 | 325 | // RequestFailureReason is an allowlisted request-failure category. It is safe | |
| 294 | 326 | // to persist; raw provider errors and response bodies are not. | |
| 295 | 327 | type RequestFailureReason string | |
| @@ -312,13 +344,15 @@ type RequestFailureLifecycle interface { | |||
| 312 | 344 | RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool) | |
| 313 | 345 | } | |
| 314 | 346 | ||
| 315 | - | // RequestContext identifies one logical agent session and one accepted turn. | |
| 316 | - | // Values are opaque provider-neutral correlation IDs. Providers may translate | |
| 317 | - | // them into cache controls or session-affinity fields, but must not replace | |
| 318 | - | // them with transport-attempt IDs. | |
| 347 | + | // RequestContext identifies the cache affinity, conversation thread, and | |
| 348 | + | // accepted turn for one logical agent request. Values are opaque | |
| 349 | + | // provider-neutral correlation IDs. Providers may translate them into cache | |
| 350 | + | // controls or routing fields, but must not replace them with transport-attempt | |
| 351 | + | // IDs. | |
| 319 | 352 | type RequestContext struct { | |
| 320 | - | SessionID string | |
| 321 | - | TurnID string | |
| 353 | + | CacheSessionID string | |
| 354 | + | ThreadID string | |
| 355 | + | TurnID string | |
| 322 | 356 | } | |
| 323 | 357 | ||
| 324 | 358 | // Request is a single LLM call. | |
| @@ -20,6 +20,9 @@ const ( | |||||
| 20 | RoleUser Role = "user" | 20 | RoleUser Role = "user" | ||
| 21 | RoleAssistant Role = "assistant" | 21 | RoleAssistant Role = "assistant" | ||
| 22 | RoleTool Role = "tool" | 22 | RoleTool Role = "tool" | ||
| 23 | + | // RoleDeveloper is host-authored provider context. Core persists it for | |||
| 24 | + | // replay but presentation layers must not render it as ordinary chat. | |||
| 25 | + | RoleDeveloper Role = "developer" | |||
| 23 | ) | 26 | ) | ||
| 24 | 27 | ||||
| 25 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | 28 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | ||
| @@ -171,13 +174,19 @@ func activatedToolNames(messages []Message) map[string]bool { | |||||
| 171 | 174 | ||||
| 172 | // Usage aggregates token counts and cost for a turn. | 175 | // Usage aggregates token counts and cost for a turn. | ||
| 173 | type Usage struct { | 176 | type Usage struct { | ||
| 174 | - | InputTokens int `json:"input_tokens"` | 177 | + | InputTokens int `json:"input_tokens"` |
| 175 | - | OutputTokens int `json:"output_tokens"` | 178 | + | OutputTokens int `json:"output_tokens"` |
| 176 | - | ReasoningTokens int `json:"reasoning_tokens"` | 179 | + | ReasoningTokens int `json:"reasoning_tokens"` |
| 177 | - | ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"` | 180 | + | ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"` |
| 178 | - | CacheReadTokens int `json:"cache_read_tokens"` | 181 | + | CacheReadTokens int `json:"cache_read_tokens"` |
| 179 | - | CacheWriteTokens int `json:"cache_write_tokens"` | 182 | + | CacheWriteTokens int `json:"cache_write_tokens"` |
| 180 | - | CostUSD float64 `json:"cost_usd"` | 183 | + | // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the |
| 184 | + | // portion of the prompt for which the provider explicitly reported cache | |||
| 185 | + | // details. Zero values mean cache details were unavailable, including in | |||
| 186 | + | // historical session rows. | |||
| 187 | + | CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"` | |||
| 188 | + | CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"` | |||
| 189 | + | CostUSD float64 `json:"cost_usd"` | |||
| 181 | } | 190 | } | ||
| 182 | 191 | ||||
| 183 | // PromptTokens returns all input tokens, regardless of cache disposition. | 192 | // PromptTokens returns all input tokens, regardless of cache disposition. | ||
| @@ -185,28 +194,30 @@ func (u Usage) PromptTokens() int { | |||||
| 185 | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | 194 | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | ||
| 186 | } | 195 | } | ||
| 187 | 196 | ||||
| 188 | - | // CacheHitRatio returns the share of prompt tokens served from cache. The | 197 | + | // CacheHitRatio returns the share of provider-measured prompt tokens served |
| 189 | - | // boolean is false when the provider reported no cache reads, which avoids | 198 | + | // from cache. The boolean is false when cache details were unavailable, which |
| 190 | - | // presenting an unsupported cache metric as a zero-percent hit rate. | 199 | + | // keeps historical or unsupported usage distinct from a known zero-percent |
| 200 | + | // cache hit rate. | |||
| 191 | func (u Usage) CacheHitRatio() (float64, bool) { | 201 | func (u Usage) CacheHitRatio() (float64, bool) { | ||
| 192 | - | total := u.PromptTokens() | 202 | + | if u.CacheMeasuredPromptTokens <= 0 { |
| 193 | - | if total <= 0 || u.CacheReadTokens <= 0 { | |||
| 194 | return 0, false | 203 | return 0, false | ||
| 195 | } | 204 | } | ||
| 196 | - | return float64(u.CacheReadTokens) / float64(total), true | 205 | + | return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true |
| 197 | } | 206 | } | ||
| 198 | 207 | ||||
| 199 | // Add returns u plus v. A reasoning total is known only when both | 208 | // Add returns u plus v. A reasoning total is known only when both | ||
| 200 | // component usage reports include a separate reasoning-token count. | 209 | // component usage reports include a separate reasoning-token count. | ||
| 201 | func (u Usage) Add(v Usage) Usage { | 210 | func (u Usage) Add(v Usage) Usage { | ||
| 202 | return Usage{ | 211 | return Usage{ | ||
| 203 | - | InputTokens: u.InputTokens + v.InputTokens, | 212 | + | InputTokens: u.InputTokens + v.InputTokens, |
| 204 | - | OutputTokens: u.OutputTokens + v.OutputTokens, | 213 | + | OutputTokens: u.OutputTokens + v.OutputTokens, |
| 205 | - | ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens, | 214 | + | ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens, |
| 206 | - | ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown, | 215 | + | ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown, |
| 207 | - | CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens, | 216 | + | CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens, |
| 208 | - | CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, | 217 | + | CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, |
| 209 | - | CostUSD: u.CostUSD + v.CostUSD, | 218 | + | CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens, |
| 219 | + | CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens, | |||
| 220 | + | CostUSD: u.CostUSD + v.CostUSD, | |||
| 210 | } | 221 | } | ||
| 211 | } | 222 | } | ||
| 212 | 223 | ||||
| @@ -290,6 +301,27 @@ type RequestAttemptIDLifecycle interface { | |||||
| 290 | RequestAttemptID(requestID string, attempt, maxAttempts int) | 301 | RequestAttemptID(requestID string, attempt, maxAttempts int) | ||
| 291 | } | 302 | } | ||
| 292 | 303 | ||||
| 304 | + | // CacheDiagnostics contains only allowlisted cache-routing state. It must not | |||
| 305 | + | // contain prompt text, durable identities, request bodies, or credentials. | |||
| 306 | + | type CacheDiagnostics struct { | |||
| 307 | + | Eligible bool | |||
| 308 | + | Mode string | |||
| 309 | + | Transport string | |||
| 310 | + | Continuation string | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // RequestCacheDiagnosticsLifecycle receives sanitized cache-routing state when | |||
| 314 | + | // an adapter can determine it. It is optional to preserve existing clients. | |||
| 315 | + | type RequestCacheDiagnosticsLifecycle interface { | |||
| 316 | + | CacheDiagnostics(CacheDiagnostics) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func ReportCacheDiagnostics(lifecycle RequestLifecycle, diagnostics CacheDiagnostics) { | |||
| 320 | + | if withDiagnostics, ok := lifecycle.(RequestCacheDiagnosticsLifecycle); ok { | |||
| 321 | + | withDiagnostics.CacheDiagnostics(diagnostics) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 293 | // RequestFailureReason is an allowlisted request-failure category. It is safe | 325 | // RequestFailureReason is an allowlisted request-failure category. It is safe | ||
| 294 | // to persist; raw provider errors and response bodies are not. | 326 | // to persist; raw provider errors and response bodies are not. | ||
| 295 | type RequestFailureReason string | 327 | type RequestFailureReason string | ||
| @@ -312,13 +344,15 @@ type RequestFailureLifecycle interface { | |||||
| 312 | RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool) | 344 | RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool) | ||
| 313 | } | 345 | } | ||
| 314 | 346 | ||||
| 315 | - | // RequestContext identifies one logical agent session and one accepted turn. | 347 | + | // RequestContext identifies the cache affinity, conversation thread, and |
| 316 | - | // Values are opaque provider-neutral correlation IDs. Providers may translate | 348 | + | // accepted turn for one logical agent request. Values are opaque |
| 317 | - | // them into cache controls or session-affinity fields, but must not replace | 349 | + | // provider-neutral correlation IDs. Providers may translate them into cache |
| 318 | - | // them with transport-attempt IDs. | 350 | + | // controls or routing fields, but must not replace them with transport-attempt |
| 351 | + | // IDs. | |||
| 319 | type RequestContext struct { | 352 | type RequestContext struct { | ||
| 320 | - | SessionID string | 353 | + | CacheSessionID string |
| 321 | - | TurnID string | 354 | + | ThreadID string |
| 355 | + | TurnID string | |||
| 322 | } | 356 | } | ||
| 323 | 357 | ||||
| 324 | // Request is a single LLM call. | 358 | // Request is a single LLM call. | ||
M packages/provider/provider_test.go +23 -4
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | |
| 15 | - | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000} | |
| 15 | + | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000} | |
| 16 | 16 | if got := u.PromptTokens(); got != 210_000 { | |
| 17 | 17 | t.Fatalf("prompt tokens = %d, want 210000", got) | |
| 18 | 18 | } | |
| @@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | |||
| 22 | 22 | } | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) { | |
| 26 | - | if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 { | |
| 27 | - | t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok) | |
| 25 | + | func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) { | |
| 26 | + | knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000} | |
| 27 | + | if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 { | |
| 28 | + | t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | historicalUnknown := Usage{InputTokens: 84_000} | |
| 32 | + | if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 { | |
| 33 | + | t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) { | |
| 38 | + | measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40} | |
| 39 | + | unknown := Usage{InputTokens: 200, CacheReadTokens: 100} | |
| 40 | + | ||
| 41 | + | got := measured.Add(unknown) | |
| 42 | + | if got.CacheMeasuredPromptTokens != 100 { | |
| 43 | + | t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens) | |
| 44 | + | } | |
| 45 | + | if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 { | |
| 46 | + | t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok) | |
| 28 | 47 | } | |
| 29 | 48 | } | |
| 30 | 49 | ||
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | 14 | func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | ||
| 15 | - | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000} | 15 | + | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000} |
| 16 | if got := u.PromptTokens(); got != 210_000 { | 16 | if got := u.PromptTokens(); got != 210_000 { | ||
| 17 | t.Fatalf("prompt tokens = %d, want 210000", got) | 17 | t.Fatalf("prompt tokens = %d, want 210000", got) | ||
| 18 | } | 18 | } | ||
| @@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | |||||
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) { | 25 | + | func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) { |
| 26 | - | if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 { | 26 | + | knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000} |
| 27 | - | t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok) | 27 | + | if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 { |
| 28 | + | t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | historicalUnknown := Usage{InputTokens: 84_000} | |||
| 32 | + | if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 { | |||
| 33 | + | t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) { | |||
| 38 | + | measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40} | |||
| 39 | + | unknown := Usage{InputTokens: 200, CacheReadTokens: 100} | |||
| 40 | + | ||||
| 41 | + | got := measured.Add(unknown) | |||
| 42 | + | if got.CacheMeasuredPromptTokens != 100 { | |||
| 43 | + | t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens) | |||
| 44 | + | } | |||
| 45 | + | if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 { | |||
| 46 | + | t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok) | |||
| 28 | } | 47 | } | ||
| 29 | } | 48 | } | ||
| 30 | 49 | ||||
M packages/provider/reasoning_usage_test.go +58 -0
| @@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) { | |||
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) { | |
| 32 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 33 | + | w.Header().Set("content-type", "text/event-stream") | |
| 34 | + | fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`) | |
| 35 | + | fmt.Fprintln(w) | |
| 36 | + | fmt.Fprintln(w, "data: [DONE]") | |
| 37 | + | fmt.Fprintln(w) | |
| 38 | + | })) | |
| 39 | + | defer srv.Close() | |
| 40 | + | ||
| 41 | + | events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"}) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | usage := usageFromEvents(events) | |
| 46 | + | if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 { | |
| 47 | + | t.Fatalf("usage = %+v", usage) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) { | |
| 52 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 53 | + | w.Header().Set("content-type", "text/event-stream") | |
| 54 | + | fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`) | |
| 55 | + | fmt.Fprintln(w) | |
| 56 | + | })) | |
| 57 | + | defer srv.Close() | |
| 58 | + | ||
| 59 | + | events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"}) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | usage := usageFromEvents(events) | |
| 64 | + | if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 { | |
| 65 | + | t.Fatalf("usage = %+v", usage) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) { | |
| 70 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 71 | + | w.Header().Set("content-type", "text/event-stream") | |
| 72 | + | fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`) | |
| 73 | + | fmt.Fprintln(w) | |
| 74 | + | fmt.Fprintln(w, "data: [DONE]") | |
| 75 | + | fmt.Fprintln(w) | |
| 76 | + | })) | |
| 77 | + | defer srv.Close() | |
| 78 | + | ||
| 79 | + | events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"}) | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | usage := usageFromEvents(events) | |
| 84 | + | if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 { | |
| 85 | + | t.Fatalf("malformed cache usage = %+v", usage) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 31 | 89 | func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) { | |
| 32 | 90 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 33 | 91 | w.Header().Set("content-type", "text/event-stream") | |
| @@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) { | |||||
| 28 | } | 28 | } | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) { | |||
| 32 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 33 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 34 | + | fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`) | |||
| 35 | + | fmt.Fprintln(w) | |||
| 36 | + | fmt.Fprintln(w, "data: [DONE]") | |||
| 37 | + | fmt.Fprintln(w) | |||
| 38 | + | })) | |||
| 39 | + | defer srv.Close() | |||
| 40 | + | ||||
| 41 | + | events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"}) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | usage := usageFromEvents(events) | |||
| 46 | + | if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 { | |||
| 47 | + | t.Fatalf("usage = %+v", usage) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) { | |||
| 52 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 53 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 54 | + | fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`) | |||
| 55 | + | fmt.Fprintln(w) | |||
| 56 | + | })) | |||
| 57 | + | defer srv.Close() | |||
| 58 | + | ||||
| 59 | + | events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"}) | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | usage := usageFromEvents(events) | |||
| 64 | + | if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 { | |||
| 65 | + | t.Fatalf("usage = %+v", usage) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) { | |||
| 70 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 71 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 72 | + | fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`) | |||
| 73 | + | fmt.Fprintln(w) | |||
| 74 | + | fmt.Fprintln(w, "data: [DONE]") | |||
| 75 | + | fmt.Fprintln(w) | |||
| 76 | + | })) | |||
| 77 | + | defer srv.Close() | |||
| 78 | + | ||||
| 79 | + | events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"}) | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | usage := usageFromEvents(events) | |||
| 84 | + | if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 { | |||
| 85 | + | t.Fatalf("malformed cache usage = %+v", usage) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 31 | func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) { | 89 | func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) { | ||
| 32 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 90 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 33 | w.Header().Set("content-type", "text/event-stream") | 91 | w.Header().Set("content-type", "text/event-stream") | ||
M packages/tui/statusbar_test.go +13 -2
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |
| 11 | 11 | lines := StatusBar(StatusBarParams{ | |
| 12 | 12 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200, | |
| 13 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000}, | |
| 13 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000}, | |
| 14 | 14 | ContextUsed: 45_152, ContextMax: 272_000, | |
| 15 | 15 | }) | |
| 16 | 16 | plain := stripANSI(strings.Join(lines, "\n")) | |
| @@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |||
| 19 | 19 | } | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) { | |
| 23 | + | lines := StatusBar(StatusBarParams{ | |
| 24 | + | Theme: Dark, Provider: "compatible", Model: "model", Cols: 200, | |
| 25 | + | Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}, | |
| 26 | + | }) | |
| 27 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 28 | + | if strings.Contains(plain, "C") { | |
| 29 | + | t.Fatalf("status bar = %q, want no cache ratio without measured details", plain) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 22 | 33 | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | |
| 23 | 34 | const cols = 30 | |
| 24 | 35 | lines := StatusBar(StatusBarParams{ | |
| 25 | 36 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols, | |
| 26 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525}, | |
| 37 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, | |
| 27 | 38 | ContextUsed: 209_000, ContextMax: 272_000, | |
| 28 | 39 | }) | |
| 29 | 40 | plain := stripANSI(strings.Join(lines, "\n")) | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | 10 | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | ||
| 11 | lines := StatusBar(StatusBarParams{ | 11 | lines := StatusBar(StatusBarParams{ | ||
| 12 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200, | 12 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200, | ||
| 13 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000}, | 13 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000}, |
| 14 | ContextUsed: 45_152, ContextMax: 272_000, | 14 | ContextUsed: 45_152, ContextMax: 272_000, | ||
| 15 | }) | 15 | }) | ||
| 16 | plain := stripANSI(strings.Join(lines, "\n")) | 16 | plain := stripANSI(strings.Join(lines, "\n")) | ||
| @@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |||||
| 19 | } | 19 | } | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | + | func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) { | |||
| 23 | + | lines := StatusBar(StatusBarParams{ | |||
| 24 | + | Theme: Dark, Provider: "compatible", Model: "model", Cols: 200, | |||
| 25 | + | Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}, | |||
| 26 | + | }) | |||
| 27 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 28 | + | if strings.Contains(plain, "C") { | |||
| 29 | + | t.Fatalf("status bar = %q, want no cache ratio without measured details", plain) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 22 | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | 33 | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | ||
| 23 | const cols = 30 | 34 | const cols = 30 | ||
| 24 | lines := StatusBar(StatusBarParams{ | 35 | lines := StatusBar(StatusBarParams{ | ||
| 25 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols, | 36 | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols, | ||
| 26 | - | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525}, | 37 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, |
| 27 | ContextUsed: 209_000, ContextMax: 272_000, | 38 | ContextUsed: 209_000, ContextMax: 272_000, | ||
| 28 | }) | 39 | }) | ||
| 29 | plain := stripANSI(strings.Join(lines, "\n")) | 40 | plain := stripANSI(strings.Join(lines, "\n")) | ||