fix(provider): verify cache continuation boundaries
- commit
- c21eb9cf092c942d66fbbdf4550dabfaea705cd8
- author
- brice · · 2026-08-18 09:12 UTC
- parent
- 808bb65
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +278 -76
M docs/providers.md +5 -2
| @@ -158,7 +158,9 @@ identity but retain distinct thread identities. The OpenAI/Codex Responses | |||
| 158 | 158 | route uses the root identity as `prompt_cache_key`; declared Codex routes also | |
| 159 | 159 | receive their documented session and thread routing headers. A generic | |
| 160 | 160 | OpenAI-compatible endpoint never receives these extensions merely because it | |
| 161 | - | accepts an OpenAI-shaped request. | |
| 161 | + | accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache | |
| 162 | + | diagnostics (`eligible`, `mode`, `transport`, and `continuation`). These | |
| 163 | + | records never include prompts, durable IDs, request bodies, or credentials. | |
| 162 | 164 | ||
| 163 | 165 | When a provider reports cache detail, zut displays cache-read hit rate as | |
| 164 | 166 | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | |
| @@ -174,7 +176,8 @@ the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | |||
| 174 | 176 | connection per conversation thread, sends one `response.create` at a time, | |
| 175 | 177 | and continues the most recent completed response with `previous_response_id` | |
| 176 | 178 | only when the complete response-context configuration and prior input prefix | |
| 177 | - | match exactly. Compatible continuations send only new tool or user input. | |
| 179 | + | match exactly, including the completed assistant output that precedes a new | |
| 180 | + | suffix. Compatible continuations send only new tool or user input. | |
| 178 | 181 | The request uses `store:false`; its only provider-specific handshake header is | |
| 179 | 182 | `Authorization: Bearer ...`, never the | |
| 180 | 183 | ChatGPT/Codex account, routing, or session headers. | |
| @@ -158,7 +158,9 @@ identity but retain distinct thread identities. The OpenAI/Codex Responses | |||||
| 158 | route uses the root identity as `prompt_cache_key`; declared Codex routes also | 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 | 159 | receive their documented session and thread routing headers. A generic | ||
| 160 | OpenAI-compatible endpoint never receives these extensions merely because it | 160 | OpenAI-compatible endpoint never receives these extensions merely because it | ||
| 161 | - | accepts an OpenAI-shaped request. | 161 | + | accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache |
| 162 | + | diagnostics (`eligible`, `mode`, `transport`, and `continuation`). These | |||
| 163 | + | records never include prompts, durable IDs, request bodies, or credentials. | |||
| 162 | 164 | ||||
| 163 | When a provider reports cache detail, zut displays cache-read hit rate as | 165 | When a provider reports cache detail, zut displays cache-read hit rate as | ||
| 164 | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | 166 | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | ||
| @@ -174,7 +176,8 @@ the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | |||||
| 174 | connection per conversation thread, sends one `response.create` at a time, | 176 | connection per conversation thread, sends one `response.create` at a time, | ||
| 175 | and continues the most recent completed response with `previous_response_id` | 177 | and continues the most recent completed response with `previous_response_id` | ||
| 176 | only when the complete response-context configuration and prior input prefix | 178 | only when the complete response-context configuration and prior input prefix | ||
| 177 | - | match exactly. Compatible continuations send only new tool or user input. | 179 | + | match exactly, including the completed assistant output that precedes a new |
| 180 | + | suffix. Compatible continuations send only new tool or user input. | |||
| 178 | The request uses `store:false`; its only provider-specific handshake header is | 181 | The request uses `store:false`; its only provider-specific handshake header is | ||
| 179 | `Authorization: Bearer ...`, never the | 182 | `Authorization: Bearer ...`, never the | ||
| 180 | ChatGPT/Codex account, routing, or session headers. | 183 | ChatGPT/Codex account, routing, or session headers. | ||
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; 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; 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/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 +24 -18
| @@ -92,12 +92,14 @@ type Image struct { | |||
| 92 | 92 | ||
| 93 | 93 | // Usage is per-turn or cumulative token / cost counts. | |
| 94 | 94 | 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"` | |
| 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 | + | CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"` | |
| 101 | + | CacheMeasuredRead int `json:"cache_measured_read,omitempty"` | |
| 102 | + | CostUSD float64 `json:"cost_usd"` | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | 105 | // State is a snapshot of the runtime's current state. | |
| @@ -182,20 +184,24 @@ func toEvent(ev core.AgentEvent) Event { | |||
| 182 | 184 | out.Result = convertContent(e.Result.Content) | |
| 183 | 185 | case core.EvUsage: | |
| 184 | 186 | 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, | |
| 187 | + | Input: e.Usage.InputTokens, | |
| 188 | + | Output: e.Usage.OutputTokens, | |
| 189 | + | Reasoning: sdkReasoningTokens(e.Usage), | |
| 190 | + | CacheRead: e.Usage.CacheReadTokens, | |
| 191 | + | CacheWrite: e.Usage.CacheWriteTokens, | |
| 192 | + | CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens, | |
| 193 | + | CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens, | |
| 194 | + | CostUSD: e.Usage.CostUSD, | |
| 191 | 195 | } | |
| 192 | 196 | 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, | |
| 197 | + | Input: e.Cumulative.InputTokens, | |
| 198 | + | Output: e.Cumulative.OutputTokens, | |
| 199 | + | Reasoning: sdkReasoningTokens(e.Cumulative), | |
| 200 | + | CacheRead: e.Cumulative.CacheReadTokens, | |
| 201 | + | CacheWrite: e.Cumulative.CacheWriteTokens, | |
| 202 | + | CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens, | |
| 203 | + | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | |
| 204 | + | CostUSD: e.Cumulative.CostUSD, | |
| 199 | 205 | } | |
| 200 | 206 | case core.EvTurnEnd: | |
| 201 | 207 | out.Stop = string(e.Stop) | |
| @@ -92,12 +92,14 @@ type Image struct { | |||||
| 92 | 92 | ||||
| 93 | // Usage is per-turn or cumulative token / cost counts. | 93 | // Usage is per-turn or cumulative token / cost counts. | ||
| 94 | type Usage struct { | 94 | type Usage struct { | ||
| 95 | - | Input int `json:"input"` | 95 | + | Input int `json:"input"` |
| 96 | - | Output int `json:"output"` | 96 | + | Output int `json:"output"` |
| 97 | - | Reasoning *int `json:"reasoning"` | 97 | + | Reasoning *int `json:"reasoning"` |
| 98 | - | CacheRead int `json:"cache_read"` | 98 | + | CacheRead int `json:"cache_read"` |
| 99 | - | CacheWrite int `json:"cache_write"` | 99 | + | CacheWrite int `json:"cache_write"` |
| 100 | - | CostUSD float64 `json:"cost_usd"` | 100 | + | CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"` |
| 101 | + | CacheMeasuredRead int `json:"cache_measured_read,omitempty"` | |||
| 102 | + | CostUSD float64 `json:"cost_usd"` | |||
| 101 | } | 103 | } | ||
| 102 | 104 | ||||
| 103 | // State is a snapshot of the runtime's current state. | 105 | // State is a snapshot of the runtime's current state. | ||
| @@ -182,20 +184,24 @@ func toEvent(ev core.AgentEvent) Event { | |||||
| 182 | out.Result = convertContent(e.Result.Content) | 184 | out.Result = convertContent(e.Result.Content) | ||
| 183 | case core.EvUsage: | 185 | case core.EvUsage: | ||
| 184 | out.Usage = &Usage{ | 186 | out.Usage = &Usage{ | ||
| 185 | - | Input: e.Usage.InputTokens, | 187 | + | Input: e.Usage.InputTokens, |
| 186 | - | Output: e.Usage.OutputTokens, | 188 | + | Output: e.Usage.OutputTokens, |
| 187 | - | Reasoning: sdkReasoningTokens(e.Usage), | 189 | + | Reasoning: sdkReasoningTokens(e.Usage), |
| 188 | - | CacheRead: e.Usage.CacheReadTokens, | 190 | + | CacheRead: e.Usage.CacheReadTokens, |
| 189 | - | CacheWrite: e.Usage.CacheWriteTokens, | 191 | + | CacheWrite: e.Usage.CacheWriteTokens, |
| 190 | - | CostUSD: e.Usage.CostUSD, | 192 | + | CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens, |
| 193 | + | CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens, | |||
| 194 | + | CostUSD: e.Usage.CostUSD, | |||
| 191 | } | 195 | } | ||
| 192 | out.Cumulative = &Usage{ | 196 | out.Cumulative = &Usage{ | ||
| 193 | - | Input: e.Cumulative.InputTokens, | 197 | + | Input: e.Cumulative.InputTokens, |
| 194 | - | Output: e.Cumulative.OutputTokens, | 198 | + | Output: e.Cumulative.OutputTokens, |
| 195 | - | Reasoning: sdkReasoningTokens(e.Cumulative), | 199 | + | Reasoning: sdkReasoningTokens(e.Cumulative), |
| 196 | - | CacheRead: e.Cumulative.CacheReadTokens, | 200 | + | CacheRead: e.Cumulative.CacheReadTokens, |
| 197 | - | CacheWrite: e.Cumulative.CacheWriteTokens, | 201 | + | CacheWrite: e.Cumulative.CacheWriteTokens, |
| 198 | - | CostUSD: e.Cumulative.CostUSD, | 202 | + | CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens, |
| 203 | + | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | |||
| 204 | + | CostUSD: e.Cumulative.CostUSD, | |||
| 199 | } | 205 | } | ||
| 200 | case core.EvTurnEnd: | 206 | case core.EvTurnEnd: | ||
| 201 | out.Stop = string(e.Stop) | 207 | out.Stop = string(e.Stop) | ||
M packages/core/agent.go +9 -0
| @@ -899,6 +899,15 @@ type requestLifecycleSink struct { | |||
| 899 | 899 | lastReason RetryReason | |
| 900 | 900 | } | |
| 901 | 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 | + | ||
| 902 | 911 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | |
| 903 | 912 | s.sink(EvRequestStarted{ | |
| 904 | 913 | Provider: s.provider, | |
| @@ -899,6 +899,15 @@ type requestLifecycleSink struct { | |||||
| 899 | lastReason RetryReason | 899 | lastReason RetryReason | ||
| 900 | } | 900 | } | ||
| 901 | 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 | + | ||||
| 902 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | 911 | func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | ||
| 903 | s.sink(EvRequestStarted{ | 912 | s.sink(EvRequestStarted{ | ||
| 904 | Provider: s.provider, | 913 | Provider: s.provider, | ||
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_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/provider/amazon_bedrock.go +3 -2
| @@ -333,7 +333,8 @@ 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 != "" { | |
| 336 | + | system, messages := systemWithDeveloperContext(req) | |
| 337 | + | if system != "" { | |
| 337 | 338 | sysBlock := map[string]interface{}{"text": system} | |
| 338 | 339 | if caching { | |
| 339 | 340 | // Append cachePoint after the system text so the stable system | |
| @@ -356,7 +357,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 356 | 357 | if out.InferenceConfig.MaxTokens == 0 { | |
| 357 | 358 | out.InferenceConfig.MaxTokens = 4096 | |
| 358 | 359 | } | |
| 359 | - | for _, m := range normalizeBedrockToolResults(req.Messages) { | |
| 360 | + | for _, m := range normalizeBedrockToolResults(messages) { | |
| 360 | 361 | role := string(m.Role) | |
| 361 | 362 | if role == "tool" { | |
| 362 | 363 | role = "user" | |
| @@ -333,7 +333,8 @@ 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 | + | system, messages := systemWithDeveloperContext(req) |
| 337 | + | if system != "" { | |||
| 337 | sysBlock := map[string]interface{}{"text": system} | 338 | sysBlock := map[string]interface{}{"text": system} | ||
| 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 | ||
| @@ -356,7 +357,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 356 | if out.InferenceConfig.MaxTokens == 0 { | 357 | if out.InferenceConfig.MaxTokens == 0 { | ||
| 357 | out.InferenceConfig.MaxTokens = 4096 | 358 | out.InferenceConfig.MaxTokens = 4096 | ||
| 358 | } | 359 | } | ||
| 359 | - | for _, m := range normalizeBedrockToolResults(req.Messages) { | 360 | + | for _, m := range normalizeBedrockToolResults(messages) { |
| 360 | role := string(m.Role) | 361 | role := string(m.Role) | ||
| 361 | if role == "tool" { | 362 | if role == "tool" { | ||
| 362 | role = "user" | 363 | role = "user" | ||
M packages/provider/anthropic.go +5 -5
| @@ -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 | + | system, 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 | |
| @@ -318,7 +318,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 318 | 318 | } | |
| 319 | 319 | } | |
| 320 | 320 | ||
| 321 | - | for _, t := range activeToolDefinitions(req.Tools, req.Messages) { | |
| 321 | + | for _, t := range activeToolDefinitions(req.Tools, messages) { | |
| 322 | 322 | name := t.Name | |
| 323 | 323 | if c.oauthTok != "" { | |
| 324 | 324 | name = toClaudeCodeToolName(name) | |
| @@ -353,11 +353,11 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 353 | 353 | // emitting them separately keeps each message bit-stable across | |
| 354 | 354 | // turns, so the cache prefix matches for the entire history up | |
| 355 | 355 | // to the newest block. | |
| 356 | - | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 357 | - | for _, msg := range req.Messages { | |
| 356 | + | messages = RepairOrphanedToolResults(messages) | |
| 357 | + | for _, msg := range messages { | |
| 358 | 358 | renameTools := c.oauthTok != "" | |
| 359 | 359 | switch msg.Role { | |
| 360 | - | case RoleDeveloper, RoleUser: | |
| 360 | + | case RoleUser: | |
| 361 | 361 | out.Messages = append(out.Messages, anthMessage{ | |
| 362 | 362 | Role: "user", | |
| 363 | 363 | Content: convertAnthContent(msg.Content, renameTools), | |
| @@ -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 | + | system, 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 | ||
| @@ -318,7 +318,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 318 | } | 318 | } | ||
| 319 | } | 319 | } | ||
| 320 | 320 | ||||
| 321 | - | for _, t := range activeToolDefinitions(req.Tools, req.Messages) { | 321 | + | for _, t := range activeToolDefinitions(req.Tools, messages) { |
| 322 | name := t.Name | 322 | name := t.Name | ||
| 323 | if c.oauthTok != "" { | 323 | if c.oauthTok != "" { | ||
| 324 | name = toClaudeCodeToolName(name) | 324 | name = toClaudeCodeToolName(name) | ||
| @@ -353,11 +353,11 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 353 | // emitting them separately keeps each message bit-stable across | 353 | // emitting them separately keeps each message bit-stable across | ||
| 354 | // turns, so the cache prefix matches for the entire history up | 354 | // turns, so the cache prefix matches for the entire history up | ||
| 355 | // to the newest block. | 355 | // to the newest block. | ||
| 356 | - | req.Messages = RepairOrphanedToolResults(req.Messages) | 356 | + | messages = RepairOrphanedToolResults(messages) |
| 357 | - | for _, msg := range req.Messages { | 357 | + | for _, msg := range messages { |
| 358 | renameTools := c.oauthTok != "" | 358 | renameTools := c.oauthTok != "" | ||
| 359 | switch msg.Role { | 359 | switch msg.Role { | ||
| 360 | - | case RoleDeveloper, RoleUser: | 360 | + | case RoleUser: |
| 361 | out.Messages = append(out.Messages, anthMessage{ | 361 | out.Messages = append(out.Messages, anthMessage{ | ||
| 362 | Role: "user", | 362 | Role: "user", | ||
| 363 | Content: convertAnthContent(msg.Content, renameTools), | 363 | Content: convertAnthContent(msg.Content, renameTools), | ||
A packages/provider/developer_context.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // systemWithDeveloperContext projects host-authored developer messages onto | |
| 6 | + | // providers that expose only a top-level system instruction. OpenAI adapters | |
| 7 | + | // preserve RoleDeveloper as a native developer input instead. | |
| 8 | + | func systemWithDeveloperContext(req Request) (string, []Message) { | |
| 9 | + | messages := make([]Message, 0, len(req.Messages)) | |
| 10 | + | context := make([]string, 0) | |
| 11 | + | for _, message := range req.Messages { | |
| 12 | + | if message.Role != RoleDeveloper { | |
| 13 | + | messages = append(messages, message) | |
| 14 | + | continue | |
| 15 | + | } | |
| 16 | + | for _, block := range message.Content { | |
| 17 | + | if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" { | |
| 18 | + | context = append(context, text.Text) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | if len(context) == 0 { | |
| 23 | + | return req.SystemPrompt(), messages | |
| 24 | + | } | |
| 25 | + | system := strings.TrimSpace(req.SystemPrompt()) | |
| 26 | + | if system != "" { | |
| 27 | + | system += "\n\n" | |
| 28 | + | } | |
| 29 | + | return system + strings.Join(context, "\n\n"), messages | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // systemWithDeveloperContext projects host-authored developer messages onto | |||
| 6 | + | // providers that expose only a top-level system instruction. OpenAI adapters | |||
| 7 | + | // preserve RoleDeveloper as a native developer input instead. | |||
| 8 | + | func systemWithDeveloperContext(req Request) (string, []Message) { | |||
| 9 | + | messages := make([]Message, 0, len(req.Messages)) | |||
| 10 | + | context := make([]string, 0) | |||
| 11 | + | for _, message := range req.Messages { | |||
| 12 | + | if message.Role != RoleDeveloper { | |||
| 13 | + | messages = append(messages, message) | |||
| 14 | + | continue | |||
| 15 | + | } | |||
| 16 | + | for _, block := range message.Content { | |||
| 17 | + | if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" { | |||
| 18 | + | context = append(context, text.Text) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | if len(context) == 0 { | |||
| 23 | + | return req.SystemPrompt(), messages | |||
| 24 | + | } | |||
| 25 | + | system := strings.TrimSpace(req.SystemPrompt()) | |||
| 26 | + | if system != "" { | |||
| 27 | + | system += "\n\n" | |||
| 28 | + | } | |||
| 29 | + | return system + strings.Join(context, "\n\n"), messages | |||
| 30 | + | } | |||
A packages/provider/developer_context_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) { | |
| 6 | + | system, messages := systemWithDeveloperContext(Request{ | |
| 7 | + | System: "stable instructions", | |
| 8 | + | Messages: []Message{ | |
| 9 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |
| 10 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |
| 11 | + | }, | |
| 12 | + | }) | |
| 13 | + | if system != "stable instructions\n\ndynamic host context" { | |
| 14 | + | t.Fatalf("system = %q", system) | |
| 15 | + | } | |
| 16 | + | if len(messages) != 1 || messages[0].Role != RoleUser { | |
| 17 | + | t.Fatalf("messages = %#v, want only the user task", messages) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) { | |
| 22 | + | system, messages := systemWithDeveloperContext(Request{ | |
| 23 | + | System: "stable instructions", | |
| 24 | + | SystemContext: "legacy host context", | |
| 25 | + | Messages: []Message{ | |
| 26 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |
| 27 | + | }, | |
| 28 | + | }) | |
| 29 | + | if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" { | |
| 30 | + | t.Fatalf("system = %q", system) | |
| 31 | + | } | |
| 32 | + | if len(messages) != 0 { | |
| 33 | + | t.Fatalf("messages = %#v, want no developer message", messages) | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) { | |||
| 6 | + | system, messages := systemWithDeveloperContext(Request{ | |||
| 7 | + | System: "stable instructions", | |||
| 8 | + | Messages: []Message{ | |||
| 9 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |||
| 10 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}}, | |||
| 11 | + | }, | |||
| 12 | + | }) | |||
| 13 | + | if system != "stable instructions\n\ndynamic host context" { | |||
| 14 | + | t.Fatalf("system = %q", system) | |||
| 15 | + | } | |||
| 16 | + | if len(messages) != 1 || messages[0].Role != RoleUser { | |||
| 17 | + | t.Fatalf("messages = %#v, want only the user task", messages) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) { | |||
| 22 | + | system, messages := systemWithDeveloperContext(Request{ | |||
| 23 | + | System: "stable instructions", | |||
| 24 | + | SystemContext: "legacy host context", | |||
| 25 | + | Messages: []Message{ | |||
| 26 | + | {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}}, | |||
| 27 | + | }, | |||
| 28 | + | }) | |||
| 29 | + | if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" { | |||
| 30 | + | t.Fatalf("system = %q", system) | |||
| 31 | + | } | |||
| 32 | + | if len(messages) != 0 { | |||
| 33 | + | t.Fatalf("messages = %#v, want no developer message", messages) | |||
| 34 | + | } | |||
| 35 | + | } | |||
M packages/provider/gemini.go +6 -5
| @@ -142,17 +142,18 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 145 | + | system, messages := systemWithDeveloperContext(req) | |
| 145 | 146 | out := &gemRequest{} | |
| 146 | 147 | ||
| 147 | 148 | // System prompt → systemInstruction.parts[0].text. | |
| 148 | - | if system := req.SystemPrompt(); strings.TrimSpace(system) != "" { | |
| 149 | + | if strings.TrimSpace(system) != "" { | |
| 149 | 150 | out.SystemInstruction = &gemSystemInstruction{ | |
| 150 | 151 | Parts: []gemPart{{Text: system}}, | |
| 151 | 152 | } | |
| 152 | 153 | } | |
| 153 | 154 | ||
| 154 | 155 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | |
| 155 | - | activeTools := activeToolDefinitions(req.Tools, req.Messages) | |
| 156 | + | activeTools := activeToolDefinitions(req.Tools, messages) | |
| 156 | 157 | ||
| 157 | 158 | // Convert tool defs. Gemini image-generation models reject function | |
| 158 | 159 | // declarations with "Function calling is not enabled for this model"; | |
| @@ -190,13 +191,13 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 190 | 191 | // Convert messages. When function calling is disabled for the target | |
| 191 | 192 | // model, also remove historical functionCall/functionResponse parts; | |
| 192 | 193 | // image models should receive only text/image content. | |
| 193 | - | msgs := req.Messages | |
| 194 | + | msgs := messages | |
| 194 | 195 | if functionsEnabled { | |
| 195 | - | msgs = RepairOrphanedToolResults(req.Messages) | |
| 196 | + | msgs = RepairOrphanedToolResults(messages) | |
| 196 | 197 | } | |
| 197 | 198 | for _, msg := range msgs { | |
| 198 | 199 | switch msg.Role { | |
| 199 | - | case RoleDeveloper, RoleUser: | |
| 200 | + | case RoleUser: | |
| 200 | 201 | parts := convertGemUserParts(msg.Content) | |
| 201 | 202 | if len(parts) == 0 { | |
| 202 | 203 | continue | |
| @@ -142,17 +142,18 @@ 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 | + | system, messages := systemWithDeveloperContext(req) | |||
| 145 | out := &gemRequest{} | 146 | out := &gemRequest{} | ||
| 146 | 147 | ||||
| 147 | // System prompt → systemInstruction.parts[0].text. | 148 | // System prompt → systemInstruction.parts[0].text. | ||
| 148 | - | if system := req.SystemPrompt(); strings.TrimSpace(system) != "" { | 149 | + | if strings.TrimSpace(system) != "" { |
| 149 | out.SystemInstruction = &gemSystemInstruction{ | 150 | out.SystemInstruction = &gemSystemInstruction{ | ||
| 150 | Parts: []gemPart{{Text: system}}, | 151 | Parts: []gemPart{{Text: system}}, | ||
| 151 | } | 152 | } | ||
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| 154 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | 155 | functionsEnabled := geminiSupportsFunctionCalling(m.ID) | ||
| 155 | - | activeTools := activeToolDefinitions(req.Tools, req.Messages) | 156 | + | activeTools := activeToolDefinitions(req.Tools, messages) |
| 156 | 157 | ||||
| 157 | // Convert tool defs. Gemini image-generation models reject function | 158 | // Convert tool defs. Gemini image-generation models reject function | ||
| 158 | // declarations with "Function calling is not enabled for this model"; | 159 | // declarations with "Function calling is not enabled for this model"; | ||
| @@ -190,13 +191,13 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 190 | // Convert messages. When function calling is disabled for the target | 191 | // Convert messages. When function calling is disabled for the target | ||
| 191 | // model, also remove historical functionCall/functionResponse parts; | 192 | // model, also remove historical functionCall/functionResponse parts; | ||
| 192 | // image models should receive only text/image content. | 193 | // image models should receive only text/image content. | ||
| 193 | - | msgs := req.Messages | 194 | + | msgs := messages |
| 194 | if functionsEnabled { | 195 | if functionsEnabled { | ||
| 195 | - | msgs = RepairOrphanedToolResults(req.Messages) | 196 | + | msgs = RepairOrphanedToolResults(messages) |
| 196 | } | 197 | } | ||
| 197 | for _, msg := range msgs { | 198 | for _, msg := range msgs { | ||
| 198 | switch msg.Role { | 199 | switch msg.Role { | ||
| 199 | - | case RoleDeveloper, RoleUser: | 200 | + | case RoleUser: |
| 200 | parts := convertGemUserParts(msg.Content) | 201 | parts := convertGemUserParts(msg.Content) | ||
| 201 | if len(parts) == 0 { | 202 | if len(parts) == 0 { | ||
| 202 | continue | 203 | continue | ||
M packages/provider/openai_codex.go +31 -13
| @@ -93,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" } | |||
| 93 | 93 | // ---- Responses API wire types (subset needed for zut's surface) ---- | |
| 94 | 94 | ||
| 95 | 95 | type codexInputText struct { | |
| 96 | - | Type string `json:"type"` // "input_text" | |
| 97 | - | Text string `json:"text"` | |
| 96 | + | Type string `json:"type"` // "input_text" | |
| 97 | + | Text string `json:"text"` | |
| 98 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | type codexInputImage struct { | |
| @@ -114,14 +115,13 @@ type codexPromptCacheOptions struct { | |||
| 114 | 115 | } | |
| 115 | 116 | ||
| 116 | 117 | type codexPromptCacheBreakpoint struct { | |
| 117 | - | Type string `json:"type"` | |
| 118 | + | Mode string `json:"mode"` | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 120 | 121 | type codexInputMessage struct { | |
| 121 | - | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 122 | - | Role string `json:"role"` | |
| 123 | - | Content []any `json:"content"` | |
| 124 | - | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |
| 122 | + | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 123 | + | Role string `json:"role"` | |
| 124 | + | Content []any `json:"content"` | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | 127 | type codexOutputMessage struct { | |
| @@ -240,9 +240,12 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 240 | 240 | // checkpoints as well as this stable instruction/tool boundary. | |
| 241 | 241 | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | |
| 242 | 242 | body.Input = append(body.Input, codexInputMessage{ | |
| 243 | - | Role: "developer", | |
| 244 | - | Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}}, | |
| 245 | - | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"}, | |
| 243 | + | Role: "developer", | |
| 244 | + | Content: []any{codexInputText{ | |
| 245 | + | Type: "input_text", | |
| 246 | + | Text: "[Stable prompt boundary]", | |
| 247 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"}, | |
| 248 | + | }}, | |
| 246 | 249 | }) | |
| 247 | 250 | } | |
| 248 | 251 | if m.Reasoning { | |
| @@ -396,6 +399,16 @@ func splitCallID(id string) (string, string) { | |||
| 396 | 399 | return id, "" | |
| 397 | 400 | } | |
| 398 | 401 | ||
| 402 | + | func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics { | |
| 403 | + | diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation} | |
| 404 | + | if wire == nil || wire.PromptCacheOptions == nil { | |
| 405 | + | return diagnostics | |
| 406 | + | } | |
| 407 | + | diagnostics.Eligible = true | |
| 408 | + | diagnostics.Mode = wire.PromptCacheOptions.Mode | |
| 409 | + | return diagnostics | |
| 410 | + | } | |
| 411 | + | ||
| 399 | 412 | func usesCodexCLIRouting(model string) bool { | |
| 400 | 413 | switch model { | |
| 401 | 414 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | |
| @@ -412,6 +425,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 412 | 425 | if err != nil { | |
| 413 | 426 | return nil, err | |
| 414 | 427 | } | |
| 428 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full")) | |
| 415 | 429 | if c.modelName != nil { | |
| 416 | 430 | wire.Model = c.modelName(wire.Model) | |
| 417 | 431 | } | |
| @@ -490,10 +504,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 490 | 504 | // event vocabulary, so keeping the state machine here prevents the transports | |
| 491 | 505 | // from drifting in tool-call, reasoning, and usage handling. | |
| 492 | 506 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | |
| 493 | - | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed) | |
| 507 | + | var observe func(string, Message) | |
| 508 | + | if completed != nil { | |
| 509 | + | observe = func(responseID string, _ Message) { completed(responseID) } | |
| 510 | + | } | |
| 511 | + | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe) | |
| 494 | 512 | } | |
| 495 | 513 | ||
| 496 | - | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) { | |
| 514 | + | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) { | |
| 497 | 515 | model, _ := c.findModel(req.Model) | |
| 498 | 516 | providerName := c.providerName | |
| 499 | 517 | if providerName == "" { | |
| @@ -729,7 +747,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 729 | 747 | stop = StopEnd | |
| 730 | 748 | } | |
| 731 | 749 | if completed != nil && p.Response.ID != "" { | |
| 732 | - | completed(p.Response.ID) | |
| 750 | + | completed(p.Response.ID, assemble()) | |
| 733 | 751 | } | |
| 734 | 752 | sendDone() | |
| 735 | 753 | return | |
| @@ -93,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" } | |||||
| 93 | // ---- Responses API wire types (subset needed for zut's surface) ---- | 93 | // ---- Responses API wire types (subset needed for zut's surface) ---- | ||
| 94 | 94 | ||||
| 95 | type codexInputText struct { | 95 | type codexInputText struct { | ||
| 96 | - | Type string `json:"type"` // "input_text" | 96 | + | Type string `json:"type"` // "input_text" |
| 97 | - | Text string `json:"text"` | 97 | + | Text string `json:"text"` |
| 98 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 100 | type codexInputImage struct { | 101 | type codexInputImage struct { | ||
| @@ -114,14 +115,13 @@ type codexPromptCacheOptions struct { | |||||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
| 116 | type codexPromptCacheBreakpoint struct { | 117 | type codexPromptCacheBreakpoint struct { | ||
| 117 | - | Type string `json:"type"` | 118 | + | Mode string `json:"mode"` |
| 118 | } | 119 | } | ||
| 119 | 120 | ||||
| 120 | type codexInputMessage struct { | 121 | type codexInputMessage struct { | ||
| 121 | - | Type string `json:"type,omitempty"` // "message" (optional for input) | 122 | + | Type string `json:"type,omitempty"` // "message" (optional for input) |
| 122 | - | Role string `json:"role"` | 123 | + | Role string `json:"role"` |
| 123 | - | Content []any `json:"content"` | 124 | + | Content []any `json:"content"` |
| 124 | - | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | type codexOutputMessage struct { | 127 | type codexOutputMessage struct { | ||
| @@ -240,9 +240,12 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 240 | // checkpoints as well as this stable instruction/tool boundary. | 240 | // checkpoints as well as this stable instruction/tool boundary. | ||
| 241 | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | 241 | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | ||
| 242 | body.Input = append(body.Input, codexInputMessage{ | 242 | body.Input = append(body.Input, codexInputMessage{ | ||
| 243 | - | Role: "developer", | 243 | + | Role: "developer", |
| 244 | - | Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}}, | 244 | + | Content: []any{codexInputText{ |
| 245 | - | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"}, | 245 | + | Type: "input_text", |
| 246 | + | Text: "[Stable prompt boundary]", | |||
| 247 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"}, | |||
| 248 | + | }}, | |||
| 246 | }) | 249 | }) | ||
| 247 | } | 250 | } | ||
| 248 | if m.Reasoning { | 251 | if m.Reasoning { | ||
| @@ -396,6 +399,16 @@ func splitCallID(id string) (string, string) { | |||||
| 396 | return id, "" | 399 | return id, "" | ||
| 397 | } | 400 | } | ||
| 398 | 401 | ||||
| 402 | + | func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics { | |||
| 403 | + | diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation} | |||
| 404 | + | if wire == nil || wire.PromptCacheOptions == nil { | |||
| 405 | + | return diagnostics | |||
| 406 | + | } | |||
| 407 | + | diagnostics.Eligible = true | |||
| 408 | + | diagnostics.Mode = wire.PromptCacheOptions.Mode | |||
| 409 | + | return diagnostics | |||
| 410 | + | } | |||
| 411 | + | ||||
| 399 | func usesCodexCLIRouting(model string) bool { | 412 | func usesCodexCLIRouting(model string) bool { | ||
| 400 | switch model { | 413 | switch model { | ||
| 401 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | 414 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | ||
| @@ -412,6 +425,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 412 | if err != nil { | 425 | if err != nil { | ||
| 413 | return nil, err | 426 | return nil, err | ||
| 414 | } | 427 | } | ||
| 428 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full")) | |||
| 415 | if c.modelName != nil { | 429 | if c.modelName != nil { | ||
| 416 | wire.Model = c.modelName(wire.Model) | 430 | wire.Model = c.modelName(wire.Model) | ||
| 417 | } | 431 | } | ||
| @@ -490,10 +504,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||||
| 490 | // event vocabulary, so keeping the state machine here prevents the transports | 504 | // event vocabulary, so keeping the state machine here prevents the transports | ||
| 491 | // from drifting in tool-call, reasoning, and usage handling. | 505 | // from drifting in tool-call, reasoning, and usage handling. | ||
| 492 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | 506 | func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { | ||
| 493 | - | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed) | 507 | + | var observe func(string, Message) |
| 508 | + | if completed != nil { | |||
| 509 | + | observe = func(responseID string, _ Message) { completed(responseID) } | |||
| 510 | + | } | |||
| 511 | + | c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe) | |||
| 494 | } | 512 | } | ||
| 495 | 513 | ||||
| 496 | - | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) { | 514 | + | func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) { |
| 497 | model, _ := c.findModel(req.Model) | 515 | model, _ := c.findModel(req.Model) | ||
| 498 | providerName := c.providerName | 516 | providerName := c.providerName | ||
| 499 | if providerName == "" { | 517 | if providerName == "" { | ||
| @@ -729,7 +747,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||||
| 729 | stop = StopEnd | 747 | stop = StopEnd | ||
| 730 | } | 748 | } | ||
| 731 | if completed != nil && p.Response.ID != "" { | 749 | if completed != nil && p.Response.ID != "" { | ||
| 732 | - | completed(p.Response.ID) | 750 | + | completed(p.Response.ID, assemble()) |
| 733 | } | 751 | } | ||
| 734 | sendDone() | 752 | sendDone() | ||
| 735 | return | 753 | return | ||
M packages/provider/openai_codex_test.go +11 -3
| @@ -212,9 +212,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 212 | 212 | if !ok || boundary["role"] != "developer" { | |
| 213 | 213 | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | |
| 214 | 214 | } | |
| 215 | - | breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any) | |
| 216 | - | if !ok || breakpoint["type"] != "stable" { | |
| 217 | - | t.Fatalf("stable cache breakpoint = %#v", boundary) | |
| 215 | + | content, ok := boundary["content"].([]any) | |
| 216 | + | if !ok || len(content) != 1 { | |
| 217 | + | t.Fatalf("stable cache boundary content = %#v", boundary) | |
| 218 | + | } | |
| 219 | + | text, ok := content[0].(map[string]any) | |
| 220 | + | if !ok || text["type"] != "input_text" { | |
| 221 | + | t.Fatalf("stable cache text block = %#v", content) | |
| 222 | + | } | |
| 223 | + | breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any) | |
| 224 | + | if !ok || breakpoint["mode"] != "explicit" { | |
| 225 | + | t.Fatalf("stable cache breakpoint = %#v", text) | |
| 218 | 226 | } | |
| 219 | 227 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | |
| 220 | 228 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | |
| @@ -212,9 +212,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||||
| 212 | if !ok || boundary["role"] != "developer" { | 212 | if !ok || boundary["role"] != "developer" { | ||
| 213 | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | 213 | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | ||
| 214 | } | 214 | } | ||
| 215 | - | breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any) | 215 | + | content, ok := boundary["content"].([]any) |
| 216 | - | if !ok || breakpoint["type"] != "stable" { | 216 | + | if !ok || len(content) != 1 { |
| 217 | - | t.Fatalf("stable cache breakpoint = %#v", boundary) | 217 | + | t.Fatalf("stable cache boundary content = %#v", boundary) |
| 218 | + | } | |||
| 219 | + | text, ok := content[0].(map[string]any) | |||
| 220 | + | if !ok || text["type"] != "input_text" { | |||
| 221 | + | t.Fatalf("stable cache text block = %#v", content) | |||
| 222 | + | } | |||
| 223 | + | breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any) | |||
| 224 | + | if !ok || breakpoint["mode"] != "explicit" { | |||
| 225 | + | t.Fatalf("stable cache breakpoint = %#v", text) | |||
| 218 | } | 226 | } | ||
| 219 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | 227 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | ||
| 220 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | 228 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | ||
M packages/provider/openai_responses_websocket.go +33 -6
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "reflect" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "sync" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "golang.org/x/net/websocket" | |
| 15 | 16 | ) | |
| @@ -39,6 +40,7 @@ type responsesWebSocketSession struct { | |||
| 39 | 40 | // lastRequest is the complete request accepted by the server. Continuation | |
| 40 | 41 | // must compare every response-context setting, not merely its transcript. | |
| 41 | 42 | lastRequest Request | |
| 43 | + | lastOutput Message | |
| 42 | 44 | lastResponseID string | |
| 43 | 45 | } | |
| 44 | 46 | ||
| @@ -179,6 +181,16 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 179 | 181 | ||
| 180 | 182 | streamReq, previousResponseID := session.incrementalRequest(req) | |
| 181 | 183 | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | |
| 184 | + | if err == nil { | |
| 185 | + | wire, wireErr := c.http.buildRequest(streamReq) | |
| 186 | + | if wireErr == nil { | |
| 187 | + | continuation := "full" | |
| 188 | + | if previousResponseID != "" { | |
| 189 | + | continuation = "incremental" | |
| 190 | + | } | |
| 191 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation)) | |
| 192 | + | } | |
| 193 | + | } | |
| 182 | 194 | if err != nil { | |
| 183 | 195 | return nil, err | |
| 184 | 196 | } | |
| @@ -242,12 +254,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 242 | 254 | ||
| 243 | 255 | completed := false | |
| 244 | 256 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | |
| 245 | - | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) { | |
| 257 | + | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) { | |
| 246 | 258 | stateMu.Lock() | |
| 247 | 259 | terminal = true | |
| 248 | 260 | stateMu.Unlock() | |
| 249 | 261 | completed = true | |
| 250 | - | session.recordCompleted(req, responseID) | |
| 262 | + | session.recordCompleted(req, output, responseID) | |
| 251 | 263 | }) | |
| 252 | 264 | close(finished) | |
| 253 | 265 | if !completed { | |
| @@ -427,15 +439,28 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, st | |||
| 427 | 439 | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | |
| 428 | 440 | return req, "" | |
| 429 | 441 | } | |
| 430 | - | // The cached response already contains the assistant output from the last | |
| 431 | - | // generation. Send only the strict new tool-result and/or user suffix. | |
| 442 | + | // The cached response already contains the exact assistant output from the | |
| 443 | + | // last generation. It must be replayed verbatim in the caller transcript | |
| 444 | + | // before we can safely send only the strict new tool-result/user suffix. | |
| 432 | 445 | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | |
| 433 | - | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |
| 446 | + | if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |
| 447 | + | if !sameResponsesOutput(req.Messages[0], s.lastOutput) { | |
| 448 | + | return req, "" | |
| 449 | + | } | |
| 434 | 450 | req.Messages = req.Messages[1:] | |
| 435 | 451 | } | |
| 436 | 452 | return req, s.lastResponseID | |
| 437 | 453 | } | |
| 438 | 454 | ||
| 455 | + | func sameResponsesOutput(current, previous Message) bool { | |
| 456 | + | // Provider message timestamps are local presentation metadata, not server | |
| 457 | + | // response state. All visible content, call IDs, reasoning replay, and | |
| 458 | + | // metadata remain part of the strict baseline. | |
| 459 | + | current.Time = time.Time{} | |
| 460 | + | previous.Time = time.Time{} | |
| 461 | + | return reflect.DeepEqual(current, previous) | |
| 462 | + | } | |
| 463 | + | ||
| 439 | 464 | func sameResponsesContinuationConfig(previous, current Request) bool { | |
| 440 | 465 | previous.Messages = nil | |
| 441 | 466 | previous.Context.TurnID = "" | |
| @@ -474,12 +499,13 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||
| 474 | 499 | return s.conn, s.raw | |
| 475 | 500 | } | |
| 476 | 501 | ||
| 477 | - | func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) { | |
| 502 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, output Message, responseID string) { | |
| 478 | 503 | s.mu.Lock() | |
| 479 | 504 | defer s.mu.Unlock() | |
| 480 | 505 | request.Messages = append([]Message(nil), request.Messages...) | |
| 481 | 506 | request.Lifecycle = nil | |
| 482 | 507 | s.lastRequest = request | |
| 508 | + | s.lastOutput = output | |
| 483 | 509 | s.lastResponseID = responseID | |
| 484 | 510 | } | |
| 485 | 511 | ||
| @@ -509,6 +535,7 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||
| 509 | 535 | s.raw = nil | |
| 510 | 536 | s.readerDone = nil | |
| 511 | 537 | s.lastRequest = Request{} | |
| 538 | + | s.lastOutput = Message{} | |
| 512 | 539 | s.lastResponseID = "" | |
| 513 | 540 | } | |
| 514 | 541 | ||
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "reflect" | 10 | "reflect" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | + | "time" | |||
| 13 | 14 | ||||
| 14 | "golang.org/x/net/websocket" | 15 | "golang.org/x/net/websocket" | ||
| 15 | ) | 16 | ) | ||
| @@ -39,6 +40,7 @@ type responsesWebSocketSession struct { | |||||
| 39 | // lastRequest is the complete request accepted by the server. Continuation | 40 | // lastRequest is the complete request accepted by the server. Continuation | ||
| 40 | // must compare every response-context setting, not merely its transcript. | 41 | // must compare every response-context setting, not merely its transcript. | ||
| 41 | lastRequest Request | 42 | lastRequest Request | ||
| 43 | + | lastOutput Message | |||
| 42 | lastResponseID string | 44 | lastResponseID string | ||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| @@ -179,6 +181,16 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 179 | 181 | ||||
| 180 | streamReq, previousResponseID := session.incrementalRequest(req) | 182 | streamReq, previousResponseID := session.incrementalRequest(req) | ||
| 181 | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | 183 | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | ||
| 184 | + | if err == nil { | |||
| 185 | + | wire, wireErr := c.http.buildRequest(streamReq) | |||
| 186 | + | if wireErr == nil { | |||
| 187 | + | continuation := "full" | |||
| 188 | + | if previousResponseID != "" { | |||
| 189 | + | continuation = "incremental" | |||
| 190 | + | } | |||
| 191 | + | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation)) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 182 | if err != nil { | 194 | if err != nil { | ||
| 183 | return nil, err | 195 | return nil, err | ||
| 184 | } | 196 | } | ||
| @@ -242,12 +254,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 242 | 254 | ||||
| 243 | completed := false | 255 | completed := false | ||
| 244 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | 256 | turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished) | ||
| 245 | - | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) { | 257 | + | c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) { |
| 246 | stateMu.Lock() | 258 | stateMu.Lock() | ||
| 247 | terminal = true | 259 | terminal = true | ||
| 248 | stateMu.Unlock() | 260 | stateMu.Unlock() | ||
| 249 | completed = true | 261 | completed = true | ||
| 250 | - | session.recordCompleted(req, responseID) | 262 | + | session.recordCompleted(req, output, responseID) |
| 251 | }) | 263 | }) | ||
| 252 | close(finished) | 264 | close(finished) | ||
| 253 | if !completed { | 265 | if !completed { | ||
| @@ -427,15 +439,28 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, st | |||||
| 427 | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | 439 | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | ||
| 428 | return req, "" | 440 | return req, "" | ||
| 429 | } | 441 | } | ||
| 430 | - | // The cached response already contains the assistant output from the last | 442 | + | // The cached response already contains the exact assistant output from the |
| 431 | - | // generation. Send only the strict new tool-result and/or user suffix. | 443 | + | // last generation. It must be replayed verbatim in the caller transcript |
| 444 | + | // before we can safely send only the strict new tool-result/user suffix. | |||
| 432 | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | 445 | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | ||
| 433 | - | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | 446 | + | if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { |
| 447 | + | if !sameResponsesOutput(req.Messages[0], s.lastOutput) { | |||
| 448 | + | return req, "" | |||
| 449 | + | } | |||
| 434 | req.Messages = req.Messages[1:] | 450 | req.Messages = req.Messages[1:] | ||
| 435 | } | 451 | } | ||
| 436 | return req, s.lastResponseID | 452 | return req, s.lastResponseID | ||
| 437 | } | 453 | } | ||
| 438 | 454 | ||||
| 455 | + | func sameResponsesOutput(current, previous Message) bool { | |||
| 456 | + | // Provider message timestamps are local presentation metadata, not server | |||
| 457 | + | // response state. All visible content, call IDs, reasoning replay, and | |||
| 458 | + | // metadata remain part of the strict baseline. | |||
| 459 | + | current.Time = time.Time{} | |||
| 460 | + | previous.Time = time.Time{} | |||
| 461 | + | return reflect.DeepEqual(current, previous) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 439 | func sameResponsesContinuationConfig(previous, current Request) bool { | 464 | func sameResponsesContinuationConfig(previous, current Request) bool { | ||
| 440 | previous.Messages = nil | 465 | previous.Messages = nil | ||
| 441 | previous.Context.TurnID = "" | 466 | previous.Context.TurnID = "" | ||
| @@ -474,12 +499,13 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||||
| 474 | return s.conn, s.raw | 499 | return s.conn, s.raw | ||
| 475 | } | 500 | } | ||
| 476 | 501 | ||||
| 477 | - | func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) { | 502 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, output Message, responseID string) { |
| 478 | s.mu.Lock() | 503 | s.mu.Lock() | ||
| 479 | defer s.mu.Unlock() | 504 | defer s.mu.Unlock() | ||
| 480 | request.Messages = append([]Message(nil), request.Messages...) | 505 | request.Messages = append([]Message(nil), request.Messages...) | ||
| 481 | request.Lifecycle = nil | 506 | request.Lifecycle = nil | ||
| 482 | s.lastRequest = request | 507 | s.lastRequest = request | ||
| 508 | + | s.lastOutput = output | |||
| 483 | s.lastResponseID = responseID | 509 | s.lastResponseID = responseID | ||
| 484 | } | 510 | } | ||
| 485 | 511 | ||||
| @@ -509,6 +535,7 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||||
| 509 | s.raw = nil | 535 | s.raw = nil | ||
| 510 | s.readerDone = nil | 536 | s.readerDone = nil | ||
| 511 | s.lastRequest = Request{} | 537 | s.lastRequest = Request{} | ||
| 538 | + | s.lastOutput = Message{} | |||
| 512 | s.lastResponseID = "" | 539 | s.lastResponseID = "" | ||
| 513 | } | 540 | } | ||
| 514 | 541 | ||||
M packages/provider/provider.go +21 -0
| @@ -301,6 +301,27 @@ type RequestAttemptIDLifecycle interface { | |||
| 301 | 301 | RequestAttemptID(requestID string, attempt, maxAttempts int) | |
| 302 | 302 | } | |
| 303 | 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 | + | ||
| 304 | 325 | // RequestFailureReason is an allowlisted request-failure category. It is safe | |
| 305 | 326 | // to persist; raw provider errors and response bodies are not. | |
| 306 | 327 | type RequestFailureReason string | |
| @@ -301,6 +301,27 @@ type RequestAttemptIDLifecycle interface { | |||||
| 301 | RequestAttemptID(requestID string, attempt, maxAttempts int) | 301 | RequestAttemptID(requestID string, attempt, maxAttempts int) | ||
| 302 | } | 302 | } | ||
| 303 | 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 | + | ||||
| 304 | // RequestFailureReason is an allowlisted request-failure category. It is safe | 325 | // RequestFailureReason is an allowlisted request-failure category. It is safe | ||
| 305 | // to persist; raw provider errors and response bodies are not. | 326 | // to persist; raw provider errors and response bodies are not. | ||
| 306 | type RequestFailureReason string | 327 | type RequestFailureReason string | ||