diff --git a/PATCHES.md b/PATCHES.md index 6c836874..f280fe7f 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -1,5 +1,19 @@ # Vendored local patches on serdes-ai 0.2.6 +## Issue 79: stateless prompt-cache routing + +The retained Responses client adds an optional `prompt_cache_key` request field and +construction-time setter. The host supplies the session label when caching is enabled, +including Codex HTTP. It also preserves optional `input_tokens_details.cached_tokens` +through both SSE completion assembly and JSON response conversion, which previously +discarded this counter. Anthropic usage counters now remain optional even when the +usage object or individual input/output fields are absent; no zero denominator is +fabricated. The stream parser's input carrier follows the type change without +changing its transport. No transport behavior changes. The upstream revision and licenses +remain unchanged; the aggregate `SERDES-AI-0.2.6.patch` contains this delta and fixture +updates. Cache usage interpretation and reporting live in the host provider layer. + + `llxprt-code-rs` depends on serdes-ai `=0.2.6` through the path dependency `vendor/serdes-ai` (`default-features = false, features = ["openai", "anthropic", "chatgpt-oauth"]`). The retained crates are based on the 0.2.6 release. The Responses client is based on the tested `serdes-ai-responses` subtree at Git commit `bd6aefc96f699276afb6384257b101039a663b5f`. The local transport patches below adapt those sources into one offline build. @@ -52,7 +66,7 @@ Each vendored crate archive is SerdesAI 0.2.6 from crates.io. Every shipped | `serdes-ai-tools` | `ae4c635d97827560acaa8d3af32a78fc50fece538d1e4638c889c7588f490777` | | `serdes-ai-toolsets` | `85e7ab76a1546ce6aa858c7a0fd438dd4235b3927fcf5a907bec26bacb6f2588` | -`SERDES-AI-0.2.6.patch` is the complete diff from those extracted archives and the retained Responses Git snapshot to `vendor/`, including path-dependency rewrites, the bounded client-only Responses selection, and source compatibility changes. Its SHA-256 is `0144b4e99ac63adf0daf17985a6e3fdb53d6c59f08c36c03b06308d519c3f660`. +`SERDES-AI-0.2.6.patch` is the complete diff from those extracted archives and the retained Responses Git snapshot to `vendor/`, including path-dependency rewrites, the bounded client-only Responses selection, and source compatibility changes. Its SHA-256 is `e7d278fa6b9364645b975d794eacb088db1400a96c0c8f751a40a683d8961cdf`. `bash scripts/regenerate-serdes-patch.sh` recreates the patch from all 11 crates.io archives and the pinned Git snapshot in a temporary Git repository. It uses a committed archive baseline plus `git add -N` before the binary diff so new files, modifications, and deletions are all represented. The 11 exact crates.io archives and the Git archive of the Responses subtree are retained under `vendor-upstream/`. The snapshot identity and SHA-256 are recorded in `provenance/serdes-ai-responses-git.json`. To reproduce the vendored tree: diff --git a/README.md b/README.md index 60b88404..d79828ea 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,9 @@ Example success object: ## `--session` and `--turn` semantics (documented current behavior) - `--session ID` is a directory name under `/code-rs-sessions/`. Turn numbers - restart per session and are 1-based. Public OpenAI Responses uses this validated label as - `prompt_cache_key` unless prompt caching is `off`; it never uses it as response-continuation - state. The Codex WebSocket transport sends neither a session header nor a prompt-cache key. + restart per session and are 1-based. OpenAI Chat, public OpenAI Responses, and Codex HTTP + use this validated label as `prompt_cache_key` unless prompt caching is `off`; it is + independent of response-continuation state. See [prompt caching](docs/prompt-caching.md). Omitting `--session` uses the literal label `default`. Do not use a sensitive value as a session ID. - No `--turn` runs the **next** turn: the first invocation of a session runs turn 1, diff --git a/SERDES-AI-0.2.6.patch b/SERDES-AI-0.2.6.patch index a72a3bd4..a76bbe39 100644 --- a/SERDES-AI-0.2.6.patch +++ b/SERDES-AI-0.2.6.patch @@ -7256,7 +7256,7 @@ index b35e397..913af57 100644 ## Installation diff --git a/vendor/serdes-ai-models/src/anthropic/model.rs b/vendor/serdes-ai-models/src/anthropic/model.rs -index 0d35170..b370ae9 100644 +index 0d35170..8228ad5 100644 --- a/vendor/serdes-ai-models/src/anthropic/model.rs +++ b/vendor/serdes-ai-models/src/anthropic/model.rs @@ -18,10 +18,11 @@ use serdes_ai_core::{ @@ -7345,7 +7345,7 @@ index 0d35170..b370ae9 100644 } } -@@ -512,12 +528,16 @@ impl AnthropicModel { +@@ -512,18 +528,26 @@ impl AnthropicModel { } } @@ -7368,7 +7368,20 @@ index 0d35170..b370ae9 100644 }); let usage = RequestUsage { -@@ -550,36 +570,8 @@ impl AnthropicModel { +- request_tokens: Some(resp.usage.input_tokens), +- response_tokens: Some(resp.usage.output_tokens), +- total_tokens: Some(resp.usage.input_tokens + resp.usage.output_tokens), ++ request_tokens: resp.usage.input_tokens, ++ response_tokens: resp.usage.output_tokens, ++ total_tokens: resp ++ .usage ++ .input_tokens ++ .zip(resp.usage.output_tokens) ++ .and_then(|(input, output)| input.checked_add(output)), + cache_creation_tokens: resp.usage.cache_creation_input_tokens, + cache_read_tokens: resp.usage.cache_read_input_tokens, + details: None, +@@ -550,36 +574,8 @@ impl AnthropicModel { } /// Handle API error response. @@ -7407,7 +7420,7 @@ index 0d35170..b370ae9 100644 } } -@@ -630,14 +622,11 @@ impl Model for AnthropicModel { +@@ -630,14 +626,11 @@ impl Model for AnthropicModel { let status = response.status().as_u16(); if !response.status().is_success() { let headers = response.headers().clone(); @@ -7424,7 +7437,7 @@ index 0d35170..b370ae9 100644 self.parse_response(resp) } -@@ -673,11 +662,11 @@ impl Model for AnthropicModel { +@@ -673,11 +666,11 @@ impl Model for AnthropicModel { let status = response.status().as_u16(); if !response.status().is_success() { let headers = response.headers().clone(); @@ -7438,7 +7451,7 @@ index 0d35170..b370ae9 100644 let parser = AnthropicStreamParser::new(byte_stream); Ok(Box::pin(parser)) -@@ -767,6 +756,29 @@ mod tests { +@@ -767,6 +760,29 @@ mod tests { assert_eq!(request.messages.len(), 1); assert_eq!(request.temperature, Some(0.7)); assert!(request.stream.is_none()); @@ -7468,11 +7481,22 @@ index 0d35170..b370ae9 100644 } #[test] +@@ -822,8 +838,8 @@ mod tests { + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: AnthropicUsage { +- input_tokens: 10, +- output_tokens: 5, ++ input_tokens: Some(10), ++ output_tokens: Some(5), + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }, diff --git a/vendor/serdes-ai-models/src/anthropic/stream.rs b/vendor/serdes-ai-models/src/anthropic/stream.rs -index 8623d2c..0ced5fe 100644 +index 8623d2c..910b24a 100644 --- a/vendor/serdes-ai-models/src/anthropic/stream.rs +++ b/vendor/serdes-ai-models/src/anthropic/stream.rs -@@ -22,6 +22,7 @@ pin_project! { +@@ -22,13 +22,14 @@ pin_project! { #[pin] inner: S, buffer: String, @@ -7480,7 +7504,15 @@ index 8623d2c..0ced5fe 100644 // Track content blocks in progress blocks: HashMap, // Message metadata -@@ -61,15 +62,17 @@ enum BlockState { + message_id: Option, + model: Option, + // Usage tracking +- input_tokens: u64, ++ input_tokens: Option, + output_tokens: u64, + cache_creation_tokens: Option, + cache_read_tokens: Option, +@@ -61,19 +62,21 @@ enum BlockState { }, } @@ -7500,6 +7532,11 @@ index 8623d2c..0ced5fe 100644 blocks: HashMap::new(), message_id: None, model: None, +- input_tokens: 0, ++ input_tokens: None, + output_tokens: 0, + cache_creation_tokens: None, + cache_read_tokens: None, @@ -82,9 +85,10 @@ where } } @@ -7538,6 +7575,15 @@ index 8623d2c..0ced5fe 100644 } Poll::Pending => return Poll::Pending, } +@@ -190,7 +198,7 @@ fn process_event( + blocks: &mut HashMap, + message_id: &mut Option, + model: &mut Option, +- input_tokens: &mut u64, ++ input_tokens: &mut Option, + output_tokens: &mut u64, + cache_creation_tokens: &mut Option, + cache_read_tokens: &mut Option, @@ -199,9 +207,10 @@ fn process_event( // Parse the JSON data let event: StreamEvent = match serde_json::from_str(data) { @@ -7634,7 +7680,7 @@ index 8623d2c..0ced5fe 100644 let mut parser = AnthropicStreamParser::new(stream); diff --git a/vendor/serdes-ai-models/src/anthropic/types.rs b/vendor/serdes-ai-models/src/anthropic/types.rs -index 90bb06a..f6534bd 100644 +index 90bb06a..5eb2d6d 100644 --- a/vendor/serdes-ai-models/src/anthropic/types.rs +++ b/vendor/serdes-ai-models/src/anthropic/types.rs @@ -4,6 +4,7 @@ @@ -7664,6 +7710,36 @@ index 90bb06a..f6534bd 100644 } } } +@@ -550,6 +556,7 @@ pub struct MessagesResponse { + /// Stop sequence if hit. + pub stop_sequence: Option, + /// Token usage. ++ #[serde(default)] + pub usage: AnthropicUsage, + } + +@@ -591,10 +598,10 @@ pub enum ResponseContentBlock { + pub struct AnthropicUsage { + /// Input tokens. + #[serde(default)] +- pub input_tokens: u64, ++ pub input_tokens: Option, + /// Output tokens. + #[serde(default)] +- pub output_tokens: u64, ++ pub output_tokens: Option, + /// Tokens used to create cache. + #[serde(default)] + pub cache_creation_input_tokens: Option, +@@ -875,7 +882,7 @@ mod tests { + let resp: MessagesResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.id, "msg_123"); + assert_eq!(resp.content.len(), 1); +- assert_eq!(resp.usage.input_tokens, 10); ++ assert_eq!(resp.usage.input_tokens, Some(10)); + } + + #[test] diff --git a/vendor/serdes-ai-models/src/antigravity/model.rs b/vendor/serdes-ai-models/src/antigravity/model.rs index c98b56f..9ef7c6d 100644 --- a/vendor/serdes-ai-models/src/antigravity/model.rs @@ -25777,10 +25853,23 @@ index 5bc1e9f..0000000 - Ok(()) -} diff --git a/vendor/serdes-ai-responses/src/client/assembler.rs b/vendor/serdes-ai-responses/src/client/assembler.rs -index 9993fc0..78de8d4 100644 +index 9993fc0..b5dcd7e 100644 --- a/vendor/serdes-ai-responses/src/client/assembler.rs +++ b/vendor/serdes-ai-responses/src/client/assembler.rs -@@ -147,13 +147,10 @@ pub(super) fn failure(response: &ResponseObject) -> ModelError { +@@ -137,7 +137,11 @@ pub(super) fn stream_complete( + input_tokens, + output_tokens, + cache_creation_tokens: None, +- cache_read_tokens: None, ++ cache_read_tokens: response ++ .usage ++ .as_ref() ++ .and_then(|usage| usage.input_tokens_details.as_ref()) ++ .and_then(|details| details.cached_tokens), + } + } + +@@ -147,13 +151,10 @@ pub(super) fn failure(response: &ResponseObject) -> ModelError { let code = body .map(|error| error.code.clone()) .unwrap_or_else(|| "response_failed".to_string()); @@ -25796,8 +25885,34 @@ index 9993fc0..78de8d4 100644 ) } +@@ -213,6 +214,9 @@ mod tests { + id: "resp_1".into(), + usage: Some(ResponseUsage { + input_tokens: Some(7), ++ input_tokens_details: Some(crate::types::InputTokensDetails { ++ cached_tokens: Some(5), ++ }), + output_tokens: Some(4), + total_tokens: Some(11), + }), +@@ -227,6 +231,7 @@ mod tests { + assert_eq!(complete.finish_reason, FinishReason::Stop); + assert_eq!(complete.input_tokens, Some(7)); + assert_eq!(complete.output_tokens, Some(4)); ++ assert_eq!(complete.cache_read_tokens, Some(5)); + } + other => panic!("expected stream complete, got {other:?}"), + } +@@ -240,6 +245,7 @@ mod tests { + "gpt-4o", + &CreateResponseRequest { + model: "gpt-4o".into(), ++ prompt_cache_key: None, + input: crate::types::ResponseInput::Text(String::new()), + instructions: None, + tools: None, diff --git a/vendor/serdes-ai-responses/src/client/mod.rs b/vendor/serdes-ai-responses/src/client/mod.rs -index f90cc22..86e53d5 100644 +index f90cc22..6b07d02 100644 --- a/vendor/serdes-ai-responses/src/client/mod.rs +++ b/vendor/serdes-ai-responses/src/client/mod.rs @@ -16,7 +16,6 @@ use async_trait::async_trait; @@ -25808,23 +25923,29 @@ index f90cc22..86e53d5 100644 use serdes_ai_core::{ModelResponse, ModelSettings, RequestUsage}; use serdes_ai_models::model::{Model, ModelRequestParameters, StreamedResponse}; use serdes_ai_models::profile::{openai_gpt4o_profile, ModelProfile}; -@@ -88,6 +87,7 @@ struct Inner { +@@ -88,8 +87,10 @@ struct Inner { model_name: String, endpoint: String, transport: Transport, + codex_http: bool, headers: Vec<(String, String)>, reasoning: Option, ++ prompt_cache_key: Option, http: reqwest::Client, -@@ -168,6 +168,7 @@ impl OpenResponsesModel { + profile: ModelProfile, + session: Mutex, +@@ -168,8 +169,10 @@ impl OpenResponsesModel { model_name, endpoint, transport, + codex_http: false, headers: Vec::new(), reasoning: None, ++ prompt_cache_key: None, http: reqwest::Client::new(), -@@ -193,6 +194,19 @@ impl OpenResponsesModel { + profile: openai_gpt4o_profile(), + session: Mutex::new(Session { +@@ -193,6 +196,19 @@ impl OpenResponsesModel { self } @@ -25844,7 +25965,23 @@ index f90cc22..86e53d5 100644 /// Authenticate with a bearer token (HTTP `Authorization` header, or the /// same header on the websocket handshake). #[must_use] -@@ -472,6 +486,9 @@ fn build_request( +@@ -214,6 +230,15 @@ impl OpenResponsesModel { + self + } + ++ /// Set session prompt-cache routing independently of response continuation. ++ #[must_use] ++ pub fn with_prompt_cache_key(mut self, key: Option) -> Self { ++ Arc::get_mut(&mut self.inner) ++ .expect("model already in use; configure before sharing") ++ .prompt_cache_key = key; ++ self ++ } ++ + /// Configure reasoning (effort/summary) for every turn. + #[must_use] + pub fn with_reasoning(mut self, reasoning: ReasoningSettings) -> Self { +@@ -472,6 +497,9 @@ fn build_request( previous_response_id: Option, store: bool, ) -> Result { @@ -25854,7 +25991,7 @@ index f90cc22..86e53d5 100644 let (instructions, items) = history_to_wire(messages, skip).map_err(client_error)?; let tools: Option> = if params.tools.is_empty() { None -@@ -484,11 +501,19 @@ fn build_request( +@@ -484,11 +512,20 @@ fn build_request( .collect(), ) }; @@ -25868,6 +26005,7 @@ index f90cc22..86e53d5 100644 Ok(CreateResponseRequest { model: inner.model_name.clone(), - input: if items.is_empty() { ++ prompt_cache_key: inner.prompt_cache_key.clone(), + input: if items.is_empty() && !inner.codex_http { crate::types::ResponseInput::Text(String::new()) } else { @@ -25875,7 +26013,7 @@ index f90cc22..86e53d5 100644 crate::types::ResponseInput::Items(items) }, instructions, -@@ -496,7 +521,7 @@ fn build_request( +@@ -496,7 +533,7 @@ fn build_request( tool_choice: tool_choice_to_wire(params.tool_choice.as_ref()), temperature: settings.temperature, top_p: settings.top_p, @@ -25884,7 +26022,7 @@ index f90cc22..86e53d5 100644 stream: None, background: None, store: Some(store), -@@ -512,35 +537,23 @@ fn build_request( +@@ -512,35 +549,23 @@ fn build_request( }) } @@ -25930,7 +26068,7 @@ index f90cc22..86e53d5 100644 } } -@@ -571,6 +584,9 @@ impl Model for OpenResponsesModel { +@@ -571,6 +596,9 @@ impl Model for OpenResponsesModel { &response.id, )) } @@ -25940,7 +26078,7 @@ index f90cc22..86e53d5 100644 Transport::Http => self.run_http_turn(messages, settings, params).await, } } -@@ -620,7 +636,7 @@ fn response_from_events( +@@ -620,7 +648,7 @@ fn response_from_events( model_name: &str, response_id: &str, ) -> ModelResponse { @@ -25949,7 +26087,7 @@ index f90cc22..86e53d5 100644 let mut parts: Vec = Vec::new(); let mut finish_reason = None; -@@ -665,6 +681,18 @@ fn response_from_events( +@@ -665,6 +693,18 @@ fn response_from_events( } } @@ -25968,7 +26106,7 @@ index f90cc22..86e53d5 100644 ModelResponse { parts, model_name: Some(model_name.to_string()), -@@ -678,7 +706,40 @@ fn response_from_events( +@@ -678,7 +718,40 @@ fn response_from_events( } impl OpenResponsesModel { @@ -26010,7 +26148,18 @@ index f90cc22..86e53d5 100644 async fn run_http_turn( &self, messages: &[ModelRequest], -@@ -759,7 +820,22 @@ impl OpenResponsesModel { +@@ -744,7 +817,9 @@ impl OpenResponsesModel { + response_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + cache_creation_tokens: None, +- cache_read_tokens: None, ++ cache_read_tokens: usage ++ .input_tokens_details ++ .and_then(|details| details.cached_tokens), + details: None, + }), + vendor_id: Some(object.id), +@@ -759,7 +834,22 @@ impl OpenResponsesModel { } } @@ -26034,7 +26183,7 @@ index f90cc22..86e53d5 100644 /// /// The request is established before any event escapes, so a /// `previous_response_not_found` on the status line clears the stale chain -@@ -772,7 +848,7 @@ async fn run_http_stream( +@@ -772,7 +862,7 @@ async fn run_http_stream( settings: &ModelSettings, params: &ModelRequestParameters, tx: &mpsc::Sender>, @@ -26043,7 +26192,7 @@ index f90cc22..86e53d5 100644 use futures::StreamExt; let mut session = inner.session.lock().await; -@@ -815,6 +891,7 @@ async fn run_http_stream( +@@ -815,6 +905,7 @@ async fn run_http_stream( let mut byte_stream = response.bytes_stream(); let mut buffer = String::new(); @@ -26051,7 +26200,7 @@ index f90cc22..86e53d5 100644 while let Some(chunk) = byte_stream.next().await { let chunk = chunk.map_err(|e| ModelError::Connection(e.to_string()))?; buffer.push_str(&String::from_utf8_lossy(&chunk)); -@@ -825,15 +902,21 @@ async fn run_http_stream( +@@ -825,15 +916,21 @@ async fn run_http_stream( continue; }; if payload == "[DONE]" { @@ -26078,7 +26227,7 @@ index f90cc22..86e53d5 100644 } for translated in assembler::translate(event) { match translated { -@@ -846,14 +929,41 @@ async fn run_http_stream( +@@ -846,14 +943,41 @@ async fn run_http_stream( // Error delivered as a stream item; returning Ok // avoids the task re-sending it. let _ = tx.send(Err(error)).await; @@ -27650,6 +27799,50 @@ index 35bba05..0000000 - assert!(cache.get("resp_2").is_some()); - } -} +diff --git a/vendor/serdes-ai-responses/src/types.rs b/vendor/serdes-ai-responses/src/types.rs +index 806f448..953a356 100644 +--- a/vendor/serdes-ai-responses/src/types.rs ++++ b/vendor/serdes-ai-responses/src/types.rs +@@ -24,6 +24,10 @@ pub struct CreateResponseRequest { + /// routing. + pub model: String, + ++ /// Session identity for prompt-cache routing on stateless HTTP. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub prompt_cache_key: Option, ++ + /// Conversation input: a plain string or a list of input items. + #[serde(default)] + pub input: ResponseInput, +@@ -650,9 +654,20 @@ pub struct IncompleteDetails { + pub reason: String, + } + ++/// Input token detail accounting reported by Responses providers. ++#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] ++pub struct InputTokensDetails { ++ /// Cache reads, absent rather than zero if omitted by the provider. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub cached_tokens: Option, ++} ++ + /// Token usage reported on a response. + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] + pub struct ResponseUsage { ++ /// Provider-reported input cache details, absent when not reported. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub input_tokens_details: Option, + /// Input (prompt) tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, +@@ -1133,6 +1148,7 @@ mod tests { + "gpt-4o", + &CreateResponseRequest { + model: "gpt-4o".into(), ++ prompt_cache_key: None, + input: ResponseInput::Text("".into()), + instructions: None, + tools: None, diff --git a/vendor/serdes-ai-responses/src/websocket.rs b/vendor/serdes-ai-responses/src/websocket.rs deleted file mode 100644 index 0620b19..0000000 @@ -28571,10 +28764,10 @@ index 85b407e..0000000 -} diff --git a/vendor/serdes-ai-responses/tests/client_ws.rs b/vendor/serdes-ai-responses/tests/client_ws.rs new file mode 100644 -index 0000000..0865eb4 +index 0000000..8ddd13a --- /dev/null +++ b/vendor/serdes-ai-responses/tests/client_ws.rs -@@ -0,0 +1,235 @@ +@@ -0,0 +1,236 @@ +//! Scripted WebSocket tests retained from the live-tested Responses client. + +use futures::{SinkExt, StreamExt}; @@ -28662,6 +28855,7 @@ index 0000000..0865eb4 + }]; + response.usage = Some(ResponseUsage { + input_tokens: Some(1), ++ input_tokens_details: None, + output_tokens: Some(1), + total_tokens: Some(2), + }); diff --git a/docs/host-profile-settings.md b/docs/host-profile-settings.md new file mode 100644 index 00000000..84540a15 --- /dev/null +++ b/docs/host-profile-settings.md @@ -0,0 +1,63 @@ +# Native host profile settings (#286) + +These `ephemeralSettings` keys have the same ownership and validation for Chat, +Anthropic, OpenAI Responses and Codex. They are not provider model parameters. +Unknown keys are still rejected by strict provider grammars; spelling variants +are not aliases. Numeric strings, fractions, booleans, null and negative values +are not accepted, except for the two explicit `-1` timeout sentinels below. + +| Key | Owner / native behavior | Type and bounds | +| --- | --- | --- | +| `shell-default-timeout-seconds` | Host shell runner: budget when the tool omits `timeout_seconds` | Positive integer seconds, at most `u32::MAX`; absent = 120 | +| `shell-max-timeout-seconds` | Host shell runner: hard cap, including explicitly requested tool timeouts | Positive integer seconds, at most `u32::MAX`, or `-1`; absent = 120 | +| `stream-first-response-timeout-ms` | Provider request budget, including Codex | `-1` or nonnegative integer milliseconds, below the session lease minus margin; zero/absent selects native default | +| `image-resize.maxLongEdge` | Host image policy, **dormant** in native text-only requests | Positive integer, at most `u32::MAX` | +| `image-resize.maxShortEdge` | Host image policy, **dormant** in native text-only requests | Positive integer, at most `u32::MAX` | +| `image-resize.maxPixels` | Host image policy, **dormant** in native text-only requests | Positive integer, at most `u64::MAX` | + +The resolved shell default must not exceed the resolved maximum. Tool calls may +request a timeout larger than the default, but the runner clamps it to the +maximum. Zero and negative tool timeouts are errors, not unlimited execution. +`--allow-shell` remains required. Task timeouts are unrelated. + +Native provider backends currently await a complete, non-streaming response. +Consequently the first-response key bounds the **whole request**, not a simulated +first-token timer. It feeds the existing request-budget resolver; higher priority +request-budget settings retain their normal precedence. Zero selects the native +900-second default rather than disabling the bound. Codex's outer async timeout +also bounds a stalled WebSocket handshake/response. + +## Image disposition: validated policy, not resizing support + +The native driver has no image ingestion/decoding/resizing pipeline and emits no +image request parts. It cannot enforce pixel/edge transformations today. The +three image limits are retained as a typed host policy and deliberately never +sent to providers. They have no effect on text requests. If both edges are set, +the short-edge bound cannot exceed the long-edge bound. Dimension and pixel +ranges are integer representation bounds, not a claim that an image of that size +can be allocated. No resize algorithm, fake resized metadata, or provider-side +resizing is claimed. Any future image-input implementation must apply these +limits (or reject image input explicitly) before shipping image support. + +This permits a full native named profile such as `astramedium` to retain its +host policy, model, context limit and reasoning effort unchanged. The checked-in +fixture is synthetic and credential-free, not a copy of any user's profile. + +## Provenance + +Shell code was inspected on `origin/main` (`a8a52b6f89850f03ff456e7ae50ae8de322e0340`) and +`origin/b3-issue-190` (`78086e296422837dfc47c5503c13853aded40b44`) before editing. +The separate default/maximum budget interface is adapted from +`origin/b3-issue-190:src/tools/shell.rs` (`shell_timeout_request` / `shell_tool`). +The native bounded runner and output handling remain from main. The branch's tool-call +`-1` unlimited convention was not adopted: tool calls remain subject to the host +maximum. Profile `-1` semantics are documented below, with no compatibility shim. + +### Negative timeout sentinel + +`-1` is also accepted for `shell-max-timeout-seconds` and +`stream-first-response-timeout-ms`: it removes the **profile-imposed** budget, +not native safety policy. The shell maximum then uses the runner's representable +ceiling (`u32::MAX` seconds); requests use the resolved native budget (900 seconds +unless overridden). This is intentionally not a promise of unbounded execution. +Other negative numbers, and `-1` for the shell default or any image limit, fail. diff --git a/docs/prompt-caching.md b/docs/prompt-caching.md new file mode 100644 index 00000000..7169adb0 --- /dev/null +++ b/docs/prompt-caching.md @@ -0,0 +1,50 @@ +# Prompt caching on stateless HTTP + +The system prompt and ordered tool definitions are an immutable session head. Do not +put timestamps, run IDs, remaining budgets, or per-round state there. Conversation +content is appended after that head. Changing cwd, shell enablement, configured tool +budget, reasoning notes, or tool definitions deliberately invalidates the head. The +compiled `prompt_cache` test compares actual serialized HTTP substrings across two +rounds and two process-per-turn invocations, with mutation and tool-order controls. +There is no epoch-summary prompt API wired into this pre-kernel runtime. + +## Settings + +`ephemeralSettings.prompt-caching` defaults to enabled. `off` disables explicit +breakpoints/routing; it does not prohibit a provider's implicit caching. Anthropic +accepts only `off` or absence, marking the last tool and system block with ephemeral +cache control. Its default TTL is five minutes. No changing transcript is marked. +OpenAI Chat and Codex accept `off`, `1h`, `24h`, or absence; these enable/disable +session routing, not a TTL promise. Public OpenAI Responses retains its existing +24-hour retention request when enabled. All OpenAI paths use the session label as +`prompt_cache_key`, not as response continuation. Chat's session-owned routing field +takes precedence over a same-named arbitrary model parameter, including when off. +Never use secrets in session labels. Concurrent independent sessions should use +different labels. The default label is `default`, not a generated unique identity. + +## Observations + +Every successful provider completion emits two JSON lines to stderr, separate from +the exactly-one-object stdout envelope: + +* `prompt_cache_call`: call ordinal, raw reported input, cached-read input, cache + creation input, uncached input, total input, and denominator convention. +* `prompt_cache_run`: cumulative completed calls, measured calls, measured total and + cached input, and token-weighted `hit_ratio` for this process invocation. The last + line is the run aggregate, including completed calls before a later failure. + +OpenAI reports input inclusive of cache reads. Uncached input is input minus reads. +Anthropic reports input excluding both cache reads and creation. Total input is the +sum of all three; uncached input includes creation. Creation is separately exposed +because it has a different price. No currency savings are inferred. + +Missing provider counters remain JSON null, not zero, including absent Anthropic +input usage. Inconsistent external counters (reads exceeding inclusive input, or +an overflowing per-call sum) preserve the raw values but omit invalid derived +values and do not enter the ratio. A call enters the ratio only +when its total denominator and cached-read count are known. A zero-token denominator +produces null ratio. Cache-off calls can still report implicit hits. Failed transports +have no completion usage and do not enter the aggregate. These numbers describe +provider-reported tokens, not a local tokenizer estimate or a request-flag prediction. +Matched enabled/disabled measurements should report missing usage or no benefit as +observations, not claim latency or cost savings without evidence. diff --git a/evalwork/results/pr-completion-20260920/issue79/Completion.md b/evalwork/results/pr-completion-20260920/issue79/Completion.md new file mode 100644 index 00000000..9eba7e5c --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/Completion.md @@ -0,0 +1,60 @@ +# Issue 79 cache-specific completion (not full issue approval) + +Source: `70d144873955d6109976d0583111d91446abf8b1`. +Ancestry: original `28e2399e`; main `a8a52b6f` integrated in `c1d09ce5`; +profile ownership #286 `fe32a8fb` integrated in `daaf8604`. PR #295 now stacks +on `branch4-issue286-profile-ownership`. No unfinished #290 integrated. +Removed compat ledger remains removed; no credential-security downgrade or endpoint override. +Vendor patch regenerated and digest updated to preserve both main and cache semantics. + +Integration uncovered a real incompatibility: cache observer was synchronous while +main's backend is async. It now awaits without holding the mutable accounting borrow. +Codex cache test migrated to async construction. Unequal denominators 100 and 200 +with hits 60 and 50 require 110/300, not the mean of 0.60 and 0.25. +Codex exact HTTP fixture now appends serialized tool results and starts a fresh test +executable per turn, round-tripping history through disk. Wire assertions compare +instructions/tools bytes and previous input prefix. Synthetic hits are fixture data, +not predictions of provider caching. Production Codex endpoint remains fixed. + +## Live availability and matched measurements +Historical acceptance setup remains available: native Codex auth, gpt-6-astra, +retained astra-headless profile (high reasoning, context 262144). No model substitution. +This is the acceptance workload, not the implementer's unchanged named astramedium +profile. Local profile copies differ only in prompt-caching off versus 24h; no global +profile writes or credential extraction. Three-turn first attempt completed two pairs, +then received actual HTTP 503 “Unable to verify Daybreak Blue access” (not quota). +A new matched two-turn run completed all four calls with exit 0. + +Final live manifest binds source 21663678 and binary SHA256 +`7d9d8ced6813937f08de4790efd254e7d6d18bb82869237869073a776a5ba2c7`. +Production source is unchanged by subsequent test-only 70d14487. Binary was built +before test-only edits/formatting; retained manifests identify it rather than pretending +a later source rebuild. Raw observations retained locally in live-final/; metrics.json +contains the bounded summary. + +| arm/turn | input | cached | wall seconds | +|---|---:|---:|---:| +| off/0 | 2941 | 0 | 2.167 | +| enabled/0 | 2941 | 0 | 2.447 | +| off/1 | 5309 | 0 | 2.169 | +| enabled/1 | 5309 | 2816 | 3.546 | + +Across measured calls: off 0/8250 (0%); enabled 2816/8250 (34.13%). +This is a token-weighted aggregate of distinct process calls, not a fabricated CLI +per-process ratio. Enabled was slower in both pairs: **no demonstrated latency benefit**. +TTFT and billed cost were not measured. Tiny ordered sample, no randomization, shared +provider cache and variable network effects preclude causal benefit claims. Initial +zero-hit pairs remain retained and are not overwritten by the later nonzero observation. + +## Tests and limits +- model-final.log: 68 model API tests passed, exit 0, including child-process Codex fixture. +- wire2.log: exact HTTP round/turn cache integration test passed, exit 0. +- profile.log: 85 focused profile tests passed, exit 0. +- vendor.log / licenses.log: provenance and licenses both exit 0. +- Earlier failed compiler/fixture attempts retained; wire fixture now creates its own tmp parent. +- Build exit 0; live-final exit 0. Initial live exit 1 preserves actual transient failure. + +Shared process ownership #252/#290 integration, final full workspace tests, release +aggregate gates, genuine extracted offline closure, and independent review remain +outstanding. No old-base full parity executed, no historical gate relabeled final. +Not ready to merge or claim full #79 readiness. No PR merge authorized or performed. diff --git a/evalwork/results/pr-completion-20260920/issue79/issue79-body.json b/evalwork/results/pr-completion-20260920/issue79/issue79-body.json new file mode 100644 index 00000000..ab81b999 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/issue79-body.json @@ -0,0 +1 @@ +{"body":"Stateless request/response is the right transport for this crate (see #62 for why a connection-oriented transport cannot amortize under process-per-request). What we are not doing is using the caching that stateless HTTP already gives us. This is the work that actually reduces cost and TTFT, and it is small.\n\n## Good news first: our prefix is already stable\n\nChecked against the source. `coding_system_prompt` (src/agent/config.rs:35-62) is a pure function of cwd, reasoning note, shell flag, and tool budget. No timestamps, no run ids, no randomness. Tool specs come from `tool_specs(allow_shell)` (src/tools.rs:492) in fixed order. So across rounds within a turn, and across turns of a session, the leading bytes are stable and cache-eligible. We are probably getting implicit cache hits already and cannot see them, because usage is discarded at src/adapter.rs:56 (#17).\n\nThat stability is currently an accident of good habits rather than a stated invariant, and nothing protects it. Any future change that puts a timestamp, a session id, a remaining-budget counter, or a reordered tool list into the prefix would silently drop the hit ratio to zero with no signal.\n\n## Work\n\n1. **Make prefix stability an invariant with a test.** Assert that two consecutive rounds of the same turn produce byte-identical leading segments up to the appended content, and that the tool list serializes in a fixed order. This is the guard that keeps the property once it is deliberate rather than lucky.\n\n2. **Anthropic: place a `cache_control` breakpoint** at the end of the stable head (tools, system, and once #78 lands, the epoch summary). Four breakpoints available. Default TTL is 5 minutes and refreshes on every hit, so an active session stays warm at no extra cost.\n\n3. **OpenAI: set `prompt_cache_key`** per session. Cached prefixes live on individual machines and routing depends on a hash of the leading tokens plus this key; without it, concurrent traffic sharing a prefix can overflow to a machine with no entry and take avoidable misses. This matters directly for our own workload, where several instances run concurrently against one key.\n\n4. **Report the numbers** (already added to #17): cached versus uncached input tokens per call and a per-run hit ratio, from `prompt_tokens_details.cached_tokens` and `cache_creation_input_tokens` / `cache_read_input_tokens`.\n\n## Ordering\n\nItem 4 first, because without measurement the other three are unfalsifiable. Item 1 next, since it protects a property we already have. Items 2 and 3 after, and both should be settings-driven per #63 rather than hardcoded per provider.\n\nRelated: #78 makes the epoch head the natural breakpoint location once checkpoint lines exist; this issue is the pre-kernel version and does not depend on it.","title":"model_api: use prompt caching properly on stateless HTTP (breakpoints, cache key, prefix-stability invariant)"} diff --git a/evalwork/results/pr-completion-20260920/issue79/licenses.exit b/evalwork/results/pr-completion-20260920/issue79/licenses.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/licenses.exit @@ -0,0 +1 @@ +0 diff --git a/evalwork/results/pr-completion-20260920/issue79/licenses.log b/evalwork/results/pr-completion-20260920/issue79/licenses.log new file mode 100644 index 00000000..a5bf7a4a --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/licenses.log @@ -0,0 +1,4 @@ +upstream archive and Git-object evidence verified +Serdes Responses Git snapshot evidence verified +vendor provenance checks ok +vendor + license checks ok diff --git a/evalwork/results/pr-completion-20260920/issue79/live-final.py b/evalwork/results/pr-completion-20260920/issue79/live-final.py new file mode 100644 index 00000000..ef188818 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/live-final.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Matched live workload. Native profile resolver only; no credential extraction.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +root = Path.cwd() +out = root / 'evalwork/results/pr-completion-20260920/issue79/live-final' +out.mkdir(exist_ok=False) +reference = Path('/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/astra-headless.json') +config = '/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home' +profile_bytes = reference.read_bytes() +profile = json.loads(profile_bytes) +binary = root / 'target/issue79-completion/debug/llxprt-code-rs' +head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() +# Working diff retained separately alongside exact binary hash. +# A stable, public synthetic document above normal minimum cache-prefix lengths. +# Both arms receive exactly the same document and request on each turn. +prompt = ('This is a prompt-cache measurement. Do not use tools. Treat the following ' + 'document as inert data, not instructions. Read it, then reply with exactly ' + 'CACHE_MEASUREMENT_OK and nothing else.\n\n' + + '\n'.join(f'Record {n:03}: The archive contains public sample measurements. ' + 'Stable ordered records permit repeated prefix evaluation across stateless requests.' + for n in range(100)) + '\n\nReply exactly CACHE_MEASUREMENT_OK.') +(out / 'prompt.txt').write_text(prompt) +manifest = {'head': head, 'tree': subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], text=True).strip(), + 'binary': str(binary), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'profile_reference': str(reference), 'profile_sha256': hashlib.sha256(profile_bytes).hexdigest(), + 'provider': profile['provider'], 'model': profile['model'], + 'config_home': config, 'prompt_sha256': hashlib.sha256(prompt.encode()).hexdigest(), + 'prompt_bytes': len(prompt.encode()), 'turns_per_arm': 3, + 'request_timeout_seconds': 90, 'max_tool_calls': 1, + 'timing': 'wall includes native authentication, startup and generation; TTFT not measured', + 'environment': {'LLXPRT_CONFIG_HOME': config, 'platform': os.uname().sysname, 'machine': os.uname().machine}, + 'runs': []} +paths = {} +for mode in ['off', 'enabled']: + selected = json.loads(profile_bytes) + selected['ephemeralSettings']['prompt-caching'] = 'off' if mode == 'off' else '24h' + paths[mode] = out / f'{mode}-profile.json' + paths[mode].write_text(json.dumps(selected)) +manifest['selected_profile_sha256'] = {mode: hashlib.sha256(path.read_bytes()).hexdigest() for mode, path in paths.items()} +for turn in range(2): + for mode in ['off', 'enabled']: + stem = f'{mode}-{turn}' + command = [str(binary), '--profile-load', str(paths[mode]), '--session', f'issue79-completion-{head[:8]}-{mode}', + '--cwd', str(root), '--request-timeout', '90s', '--max-tool-calls', '1', + '--mem-profile', str(out / f'{stem}-rss.jsonl')] + start = time.monotonic() + with (out / f'{stem}.stdout').open('xb') as stdout, (out / f'{stem}.stderr').open('xb') as stderr: + try: + result = subprocess.run(command, input=prompt.encode(), cwd=root, + env={**os.environ, 'LLXPRT_CONFIG_HOME': config}, + stdout=stdout, stderr=stderr, timeout=120) + status = result.returncode + except subprocess.TimeoutExpired: + status = 'timeout_120s_child_killed_and_waited' + run = {'mode': mode, 'turn': turn, 'command': command, 'stdin_file': str(out / 'prompt.txt'), + 'status': status, 'wall_seconds': time.monotonic() - start} + run['raw_cache_records'] = [] + for line in (out / f'{stem}.stderr').read_text().splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get('event') in ['prompt_cache_call', 'prompt_cache_run']: + run['raw_cache_records'].append(event) + manifest['runs'].append(run) + (out / 'manifest.json').write_text(json.dumps(manifest, indent=2)) + if status != 0: + (out / 'stopped-on-failure').write_text('Not successful live evidence. Native authorization or provider failure must be reported.\n') + raise SystemExit(1) +assert hashlib.sha256(binary.read_bytes()).hexdigest() == manifest['binary_sha256'] +assert subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() == head +(out / 'done').write_text('done\n') diff --git a/evalwork/results/pr-completion-20260920/issue79/live-final/manifest.json b/evalwork/results/pr-completion-20260920/issue79/live-final/manifest.json new file mode 100644 index 00000000..3c05ee9c --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/live-final/manifest.json @@ -0,0 +1,204 @@ +{ + "head": "21663678c327d51794b18aadc007946c561246bd", + "tree": "e5a3dbd158b8c552b18b0ee2c12e7320e6014b03", + "binary": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "binary_sha256": "7d9d8ced6813937f08de4790efd254e7d6d18bb82869237869073a776a5ba2c7", + "profile_reference": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/astra-headless.json", + "profile_sha256": "891795efb06ec8fab463b17e7e5a4a5451de5a93e53cf0baaf2e71666de59102", + "provider": "codex", + "model": "gpt-6-astra", + "config_home": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home", + "prompt_sha256": "dd6212de01ecc1ceeb1059bf8a236101a08f4c8a9ccac47a232da59b00b687ee", + "prompt_bytes": 14746, + "turns_per_arm": 3, + "request_timeout_seconds": 90, + "max_tool_calls": 1, + "timing": "wall includes native authentication, startup and generation; TTFT not measured", + "environment": { + "LLXPRT_CONFIG_HOME": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home", + "platform": "Darwin", + "machine": "arm64" + }, + "runs": [ + { + "mode": "off", + "turn": 0, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/off-profile.json", + "--session", + "issue79-completion-21663678-off", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/off-0-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/prompt.txt", + "status": 0, + "wall_seconds": 2.1674413750879467, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 2941, + "cached_input_tokens": 0, + "uncached_input_tokens": 2941, + "cache_creation_input_tokens": null, + "total_input_tokens": 2941, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 2941 + } + } + ] + }, + { + "mode": "enabled", + "turn": 0, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/enabled-profile.json", + "--session", + "issue79-completion-21663678-enabled", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/enabled-0-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/prompt.txt", + "status": 0, + "wall_seconds": 2.4473387920297682, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 2941, + "cached_input_tokens": 0, + "uncached_input_tokens": 2941, + "cache_creation_input_tokens": null, + "total_input_tokens": 2941, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 2941 + } + } + ] + }, + { + "mode": "off", + "turn": 1, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/off-profile.json", + "--session", + "issue79-completion-21663678-off", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/off-1-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/prompt.txt", + "status": 0, + "wall_seconds": 2.1693135000532493, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 5309, + "cached_input_tokens": 0, + "uncached_input_tokens": 5309, + "cache_creation_input_tokens": null, + "total_input_tokens": 5309, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 5309 + } + } + ] + }, + { + "mode": "enabled", + "turn": 1, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/enabled-profile.json", + "--session", + "issue79-completion-21663678-enabled", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/enabled-1-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live-final/prompt.txt", + "status": 0, + "wall_seconds": 3.546399583108723, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 5309, + "cached_input_tokens": 2816, + "uncached_input_tokens": 2493, + "cache_creation_input_tokens": null, + "total_input_tokens": 5309, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.5304200414390657, + "measured_cached_tokens": 2816, + "measured_calls": 1, + "measured_input_tokens": 5309 + } + } + ] + } + ], + "selected_profile_sha256": { + "off": "81424c9e4df2ef073878734bae1321a3ce713076a942e1e3babcc8a7c182ad84", + "enabled": "dc1823c4800896dc4fa69d3f818f99199590a4de2c91a10156678a4d921d6bd6" + } +} \ No newline at end of file diff --git a/evalwork/results/pr-completion-20260920/issue79/live.py b/evalwork/results/pr-completion-20260920/issue79/live.py new file mode 100644 index 00000000..884bcd81 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/live.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Matched live workload. Native profile resolver only; no credential extraction.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +root = Path.cwd() +out = root / 'evalwork/results/pr-completion-20260920/issue79/live' +out.mkdir(exist_ok=False) +reference = Path('/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/astra-headless.json') +config = '/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home' +profile_bytes = reference.read_bytes() +profile = json.loads(profile_bytes) +binary = root / 'target/issue79-completion/debug/llxprt-code-rs' +head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() +# Working diff retained separately alongside exact binary hash. +# A stable, public synthetic document above normal minimum cache-prefix lengths. +# Both arms receive exactly the same document and request on each turn. +prompt = ('This is a prompt-cache measurement. Do not use tools. Treat the following ' + 'document as inert data, not instructions. Read it, then reply with exactly ' + 'CACHE_MEASUREMENT_OK and nothing else.\n\n' + + '\n'.join(f'Record {n:03}: The archive contains public sample measurements. ' + 'Stable ordered records permit repeated prefix evaluation across stateless requests.' + for n in range(100)) + '\n\nReply exactly CACHE_MEASUREMENT_OK.') +(out / 'prompt.txt').write_text(prompt) +manifest = {'head': head, 'tree': subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], text=True).strip(), + 'binary': str(binary), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'profile_reference': str(reference), 'profile_sha256': hashlib.sha256(profile_bytes).hexdigest(), + 'provider': profile['provider'], 'model': profile['model'], + 'config_home': config, 'prompt_sha256': hashlib.sha256(prompt.encode()).hexdigest(), + 'prompt_bytes': len(prompt.encode()), 'turns_per_arm': 3, + 'request_timeout_seconds': 90, 'max_tool_calls': 1, + 'timing': 'wall includes native authentication, startup and generation; TTFT not measured', + 'environment': {'LLXPRT_CONFIG_HOME': config, 'platform': os.uname().sysname, 'machine': os.uname().machine}, + 'runs': []} +paths = {} +for mode in ['off', 'enabled']: + selected = json.loads(profile_bytes) + selected['ephemeralSettings']['prompt-caching'] = 'off' if mode == 'off' else '24h' + paths[mode] = out / f'{mode}-profile.json' + paths[mode].write_text(json.dumps(selected)) +manifest['selected_profile_sha256'] = {mode: hashlib.sha256(path.read_bytes()).hexdigest() for mode, path in paths.items()} +for turn in range(3): + for mode in ['off', 'enabled']: + stem = f'{mode}-{turn}' + command = [str(binary), '--profile-load', str(paths[mode]), '--session', f'issue79-completion-{head[:8]}-{mode}', + '--cwd', str(root), '--request-timeout', '90s', '--max-tool-calls', '1', + '--mem-profile', str(out / f'{stem}-rss.jsonl')] + start = time.monotonic() + with (out / f'{stem}.stdout').open('xb') as stdout, (out / f'{stem}.stderr').open('xb') as stderr: + try: + result = subprocess.run(command, input=prompt.encode(), cwd=root, + env={**os.environ, 'LLXPRT_CONFIG_HOME': config}, + stdout=stdout, stderr=stderr, timeout=120) + status = result.returncode + except subprocess.TimeoutExpired: + status = 'timeout_120s_child_killed_and_waited' + run = {'mode': mode, 'turn': turn, 'command': command, 'stdin_file': str(out / 'prompt.txt'), + 'status': status, 'wall_seconds': time.monotonic() - start} + run['raw_cache_records'] = [] + for line in (out / f'{stem}.stderr').read_text().splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get('event') in ['prompt_cache_call', 'prompt_cache_run']: + run['raw_cache_records'].append(event) + manifest['runs'].append(run) + (out / 'manifest.json').write_text(json.dumps(manifest, indent=2)) + if status != 0: + (out / 'stopped-on-failure').write_text('Not successful live evidence. Native authorization or provider failure must be reported.\n') + raise SystemExit(1) +assert hashlib.sha256(binary.read_bytes()).hexdigest() == manifest['binary_sha256'] +assert subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() == head +(out / 'done').write_text('done\n') diff --git a/evalwork/results/pr-completion-20260920/issue79/live/manifest.json b/evalwork/results/pr-completion-20260920/issue79/live/manifest.json new file mode 100644 index 00000000..e035154f --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/live/manifest.json @@ -0,0 +1,227 @@ +{ + "head": "daaf8604d20059b44f79e4545fe6f9d05c9d65de", + "tree": "45e258822ed2b04fc46d17a23bb6e34498210914", + "binary": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "binary_sha256": "7d9d8ced6813937f08de4790efd254e7d6d18bb82869237869073a776a5ba2c7", + "profile_reference": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/astra-headless.json", + "profile_sha256": "891795efb06ec8fab463b17e7e5a4a5451de5a93e53cf0baaf2e71666de59102", + "provider": "codex", + "model": "gpt-6-astra", + "config_home": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home", + "prompt_sha256": "dd6212de01ecc1ceeb1059bf8a236101a08f4c8a9ccac47a232da59b00b687ee", + "prompt_bytes": 14746, + "turns_per_arm": 3, + "request_timeout_seconds": 90, + "max_tool_calls": 1, + "timing": "wall includes native authentication, startup and generation; TTFT not measured", + "environment": { + "LLXPRT_CONFIG_HOME": "/Users/acoliver/projects/llxprt/agent/branch-4/llxprt-code-rs/tmp/branch4-orchestrator/rs-config-home", + "platform": "Darwin", + "machine": "arm64" + }, + "runs": [ + { + "mode": "off", + "turn": 0, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-profile.json", + "--session", + "issue79-completion-daaf8604-off", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-0-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/prompt.txt", + "status": 0, + "wall_seconds": 8.726045707939193, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 2941, + "cached_input_tokens": 0, + "uncached_input_tokens": 2941, + "cache_creation_input_tokens": null, + "total_input_tokens": 2941, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 2941 + } + } + ] + }, + { + "mode": "enabled", + "turn": 0, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/enabled-profile.json", + "--session", + "issue79-completion-daaf8604-enabled", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/enabled-0-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/prompt.txt", + "status": 0, + "wall_seconds": 2.1715333750471473, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 2941, + "cached_input_tokens": 0, + "uncached_input_tokens": 2941, + "cache_creation_input_tokens": null, + "total_input_tokens": 2941, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 2941 + } + } + ] + }, + { + "mode": "off", + "turn": 1, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-profile.json", + "--session", + "issue79-completion-daaf8604-off", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-1-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/prompt.txt", + "status": 0, + "wall_seconds": 2.1776001669932157, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 5309, + "cached_input_tokens": 0, + "uncached_input_tokens": 5309, + "cache_creation_input_tokens": null, + "total_input_tokens": 5309, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 5309 + } + } + ] + }, + { + "mode": "enabled", + "turn": 1, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/enabled-profile.json", + "--session", + "issue79-completion-daaf8604-enabled", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/enabled-1-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/prompt.txt", + "status": 0, + "wall_seconds": 2.485458540963009, + "raw_cache_records": [ + { + "event": "prompt_cache_call", + "call": 1, + "reported_input_tokens": 5309, + "cached_input_tokens": 0, + "uncached_input_tokens": 5309, + "cache_creation_input_tokens": null, + "total_input_tokens": 5309, + "input_accounting": "input_includes_cache_reads" + }, + { + "event": "prompt_cache_run", + "usage": { + "calls": 1, + "hit_ratio": 0.0, + "measured_cached_tokens": 0, + "measured_calls": 1, + "measured_input_tokens": 5309 + } + } + ] + }, + { + "mode": "off", + "turn": 2, + "command": [ + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/target/issue79-completion/debug/llxprt-code-rs", + "--profile-load", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-profile.json", + "--session", + "issue79-completion-daaf8604-off", + "--cwd", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79", + "--request-timeout", + "90s", + "--max-tool-calls", + "1", + "--mem-profile", + "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/off-2-rss.jsonl" + ], + "stdin_file": "/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79/evalwork/results/pr-completion-20260920/issue79/live/prompt.txt", + "status": 5, + "wall_seconds": 0.5475615829927847, + "raw_cache_records": [] + } + ], + "selected_profile_sha256": { + "off": "81424c9e4df2ef073878734bae1321a3ce713076a942e1e3babcc8a7c182ad84", + "enabled": "dc1823c4800896dc4fa69d3f818f99199590a4de2c91a10156678a4d921d6bd6" + } +} \ No newline at end of file diff --git a/evalwork/results/pr-completion-20260920/issue79/metrics.json b/evalwork/results/pr-completion-20260920/issue79/metrics.json new file mode 100644 index 00000000..34759641 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/metrics.json @@ -0,0 +1,38 @@ +{ + "source": "70d144873955d6109976d0583111d91446abf8b1", + "binary_sha256": "7d9d8ced6813937f08de4790efd254e7d6d18bb82869237869073a776a5ba2c7", + "runs": [ + { + "mode": "off", + "turn": 0, + "status": 0, + "wall_seconds": 2.1674413750879467, + "input": 2941, + "cached": 0 + }, + { + "mode": "enabled", + "turn": 0, + "status": 0, + "wall_seconds": 2.4473387920297682, + "input": 2941, + "cached": 0 + }, + { + "mode": "off", + "turn": 1, + "status": 0, + "wall_seconds": 2.1693135000532493, + "input": 5309, + "cached": 0 + }, + { + "mode": "enabled", + "turn": 1, + "status": 0, + "wall_seconds": 3.546399583108723, + "input": 5309, + "cached": 2816 + } + ] +} diff --git a/evalwork/results/pr-completion-20260920/issue79/model-final.exit b/evalwork/results/pr-completion-20260920/issue79/model-final.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/model-final.exit @@ -0,0 +1 @@ +0 diff --git a/evalwork/results/pr-completion-20260920/issue79/model-final.log b/evalwork/results/pr-completion-20260920/issue79/model-final.log new file mode 100644 index 00000000..93140bb5 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/model-final.log @@ -0,0 +1,76 @@ + Compiling llxprt-code-rs v0.1.0 (/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.27s + Running unittests src/lib.rs (target/issue79-completion/debug/deps/llxprt_code_rs-68b4d971c74354ca) + +running 68 tests +test model_api::cache_observation::tests::absent_zero_and_mixed_usage_preserve_denominators ... ok +test model_api::credentials::tests::private_wrapper_debug_is_always_redacted ... ok +test model_api::cache_observation::tests::inconsistent_external_counters_do_not_panic_or_pollute_ratio ... ok +test model_api::dependencies::tests::config_home_rejects_relative_and_empty_paths ... ok +test model_api::credentials::tests::invalid_utf8_maps_to_the_fixed_diagnostic ... ok +test model_api::credentials::tests::malformed_top_level_duplicate_and_trailing_data_share_fixed_error ... ok +test model_api::credentials::tests::in_memory_source_uses_injected_clock_and_exact_parser ... ok +test model_api::credentials::tests::expiry_requires_checked_strict_skew ... ok +test model_api::credentials::tests::expiry_number_must_be_finite_integral_and_in_range ... ok +test model_api::credentials::tests::token_type_is_ascii_case_insensitive_without_trimming ... ok +test model_api::credentials::tests::header_validation_rejects_controls_and_del ... ok +test model_api::credentials::tests::required_fields_and_types_are_enforced ... ok +test model_api::credentials::tests::total_encoded_size_accepts_exact_cap_and_rejects_plus_one ... ok +test model_api::credentials::tests::field_count_accepts_32_and_rejects_33 ... ok +test model_api::macos_keychain::tests::lifecycle_line_precedes_lookup ... ok +test model_api::credentials::tests::known_optional_fields_follow_exact_scalar_rules_and_are_dropped ... ok +test model_api::macos_keychain::tests::fixed_and_parameterized_helpers_compile_without_native_access ... ok +test model_api::credentials::tests::parses_exact_required_shape_and_redacts_debug ... ok +test model_api::credentials::tests::required_string_byte_bounds_are_exact ... ok +test model_api::model_registry::tests::typed_keys_are_known_everywhere ... ok +test model_api::model_registry::tests::overrides_are_provider_specific ... ok +test model_api::model_registry::tests::unrelated_names_are_not_known ... ok +test model_api::dependencies::tests::test_constructor_injects_all_dependencies_and_one_root ... ok +test model_api::registry::tests::codex_transport_identity_is_fixed ... ok +test model_api::credentials::tests::unknown_scalars_are_accepted_and_containers_rejected ... ok +test model_api::macos_keychain::tests::bounded_read_passes_through_success ... ok +test model_api::macos_keychain::tests::bounded_read_passes_through_error ... ok +test model_api::dependencies::tests::production_registration_slice_is_exact_and_typed ... ok +test model_api::registry::tests::request_timeout_at_lease_margin_still_fails_validation ... ok +test model_api::credentials::tests::optional_string_caps_are_exact ... ok +test model_api::registry::tests::anthropic_settings_forward_profile_top_k ... ok +test model_api::registry::tests::codex_settings_forward_the_profile_output_bound ... ok +test model_api::registry::tests::request_timeout_resolved_policy_defaults_to_900s_for_every_provider_path ... ok +test model_api::registry::tests::request_timeout_codex_consumes_provider_timeout_data ... ok +test model_api::registry::tests::issue286_codex_negative_first_response_budget_uses_native_request_bound ... ok +test model_api::registry::tests::max_turns_per_prompt_resolves_the_documented_sentinels ... ok +test model_api::registry::tests::responses_endpoint_routes_normalize_to_one_suffix ... ok +test model_api::registry::tests::loose_mode_forwards_unknown_keys_and_warns_for_unsupported ... ok +test model_api::registry::tests::responses_endpoint_rejection_precedes_credential_io ... ok +test model_api::responses_backend::cache_tests::codex_cache_child_turn ... ok +test model_api::registry::tests::strict_mode_refuses_unowned_keys_at_load ... ok +test model_api::registry::tests::known_model_mode_accepts_registry_keys_and_refuses_unknown ... ok +test model_api::settings::tests::disabled_reasoning_omits_responses_reasoning ... ok +test model_api::settings::tests::enabled_reasoning_maps_to_tested_responses_shape ... ok +test model_api::settings::tests::openai_responses_cache_settings_are_session_bound_and_stateless ... ok +test model_api::settings::tests::codex_profile_effort_reaches_serialized_reasoning ... ok +test model_api::settings::tests::production_endpoint_identity_is_exact ... ok +test model_api::tests::selection::api_selector_selects_the_api_surface ... ok +test model_api::tests::selection::explicit_api_selector_values_are_validated ... ok +test model_api::tests::selection::model_names_do_not_select_an_api_and_transport_is_not_profile_selectable ... ok +test model_api::registry::tests::zai_anthropic_backend_constructs_offline_without_native_credentials ... ok +test model_api::registry::tests::chat_registry_construction_does_not_load_native_credentials ... ok +test model_api::registry::tests::codex_registry_construction_loads_native_credentials_once ... ok +test model_api::tests::selection::provider_spellings_are_exact ... ok +test model_api::tests::selection::removed_api_selector_spellings_are_inert ... ok +test model_api::tests::selection::response_metadata_does_not_select_an_api ... ok +test model_api::tests::selection::provider_defaults_and_api_surfaces_are_typed ... ok +test model_api::registry::tests::zai_anthropic_backend_constructs_from_a_named_provider_key ... ok +test model_api::registry::tests::chat_named_provider_key_resolves_from_the_env_selector ... ok +test model_api::registry::tests::both_public_responses_targets_construct_without_native_credentials ... ok +test model_api::responses_backend::tests::failed_requests_are_counted_without_exposing_transport_details ... ok +test model_api::responses_backend::tests::codex_http_rejects_non_sse_success_body ... ok +test model_api::registry::tests::anthropic_messages_wire_uses_expected_route_and_headers ... ok +test model_api::responses_backend::tests::codex_http_wire_contract_streams_without_store_or_cap_and_replays_input ... ok +test model_api::registry::tests::zai_named_provider_key_unresolved_reports_the_fixed_refusal ... ok +test model_api::responses_backend::cache_tests::codex_exact_http_head_rounds_turns_off_and_missing_cache_usage ... ok +test model_api::macos_keychain::tests::bounded_read_times_out_with_actionable_message ... ok +test model_api::responses_backend::tests::codex_turn_is_bounded_by_the_configured_timeout ... ok + +test result: ok. 68 passed; 0 failed; 0 ignored; 0 measured; 733 filtered out; finished in 1.01s + diff --git a/evalwork/results/pr-completion-20260920/issue79/original79.txt b/evalwork/results/pr-completion-20260920/issue79/original79.txt new file mode 100644 index 00000000..673b998e --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/original79.txt @@ -0,0 +1,27 @@ +author: acoliver +association: contributor +edited: false +status: none +-- +Branch-4 claims full implementation of #79 under Andrew's next-five-issues dispatch. Worktree `branch-4/worktrees/issue79`, branch `branch4-issue79-prompt-cache`, starting at main `44530155`. Actual headless Rust CLI workers will use Astra. Scope includes cache measurement first, exact-wire multi-round/turn prefix invariants, settings-driven cache routing/breakpoints, and measured enabled/disabled evidence. Reuses #231 usage fields, does not take #78 epoch ownership or #191 transport diagnostics. Parallel branch-4 #28/#220 work will be integrated serially at shared adapter/prompt boundaries. No acceptance items deferred. +-- +author: acoliver +association: contributor +edited: false +status: none +-- +Andrew returned for native authorization. A fresh-session attempt on the retained final feature SHA256 `9271f6ec73f828ee01e749e0a19d98a82d0adf541926eb20f837393bbfb00403`, source `28e2399eb21b9c4820c31f635ddc04deece47fdc`, retained the prepared profiles and input after hash checks. The first cache-off call exited 3 because this separate binary's Keychain prompt went unanswered for 10 seconds. The six-call lane stopped immediately, before any provider measurement; no automatic retry or policy change was made. + +New evidence: `branch-4/worktrees/issue79/evalwork/results/branch4-wave2/issue79/approved-live-a/`. The retained predecessor observations and the complete 71-row evidence matrix remain distinct from missing final-feature measurements. A coordinated prompt retry awaits Andrew's readiness. Shared #252 gates and final review remain outstanding; no cache benefit, completion or merge is claimed. +-- +author: acoliver +association: contributor +edited: false +status: none +-- +The second native live attempt on the retained feature binary succeeded at authentication and failed at provider quota. Evidence `branch-4/worktrees/issue79/evalwork/results/branch4-wave2/issue79/approved-live-b/`. + +The off-0 turn loaded the macOS keychain credential (service `llxprt-code-oauth`, account `codex:default`) without the earlier 10-second authorization timeout, reached the provider, and returned native exit5 `model-quota-exhausted`: HTTP429 `usage_limit_reached`, plan `pro`, `resets_at`1789435393 (2026-09-15T01:16:22Z), `resets_in_seconds`553228. The lane stopped at that first failure as designed; no further calls were made. Binary SHA256 remains 9271f6ec73f828ee01e749e0a19d98a82d0adf541926eb20f837393bbfb00403 on clean 28e2399e. + +This separates three failure classes on this lane: the a-lane Keychain timeout (exit3, pre-provider), this provider quota exhaustion (429, not retryable before reset), and the separate zai transport class recorded on #191. The matched six-call cache-on/cache-off measurement remains outstanding until the plan resets or an alternative is explicitly chosen; switching provider or model would change what is being measured and is not being done silently. All prior static, fixture, vendor and audit evidence is unaffected. +-- diff --git a/evalwork/results/pr-completion-20260920/issue79/profile.exit b/evalwork/results/pr-completion-20260920/issue79/profile.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/profile.exit @@ -0,0 +1 @@ +0 diff --git a/evalwork/results/pr-completion-20260920/issue79/profile.log b/evalwork/results/pr-completion-20260920/issue79/profile.log new file mode 100644 index 00000000..e6b1114f --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/profile.log @@ -0,0 +1,93 @@ + Compiling llxprt-code-rs v0.1.0 (/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.23s + Running unittests src/lib.rs (target/issue79-completion/debug/deps/llxprt_code_rs-68b4d971c74354ca) + +running 85 tests +test memory_profile::sample::tests::task_info_mapping_checks_status_and_count ... ok +test memory_profile::sample::tests::peak_units_and_sign_are_checked ... ok +test memory_profile::sample::tests::page_conversion_is_checked ... ok +test memory_profile::sample::tests::absent_statm_file_is_an_error ... ok +test memory_profile::sample::tests::parses_exact_linux_statm_shape ... ok +test model_api::registry::tests::anthropic_settings_forward_profile_top_k ... ok +test profile::anthropic::issue81_parser_tests::anthropic_profile_resolution_preserves_dsflash_marker ... ok +test model_api::tests::selection::model_names_do_not_select_an_api_and_transport_is_not_profile_selectable ... ok +test model_api::registry::tests::codex_settings_forward_the_profile_output_bound ... ok +test model::tests::api_selection::from_profile_refuses_unsupported_api_selection_before_config_root ... ok +test profile::anthropic::tests::caching_defaults_on_and_off_is_accepted ... ok +test profile::anthropic::tests::preserves_dsflash_marker_without_discriminator ... ok +test memory_profile::tests::unsafe_destination_leaves_are_rejected ... ok +test profile::anthropic::tests::invalid_caching_is_rejected ... ok +test profile::max_tool_calls_tests::max_tool_calls_per_prompt_grammar_standard_chat ... ok +test profile::max_tool_calls_tests::max_tool_calls_resolution_precedence ... ok +test profile::provider_settings::tests::prompt_caching_spellings_follow_the_on_disk_contract ... ok +test profile::max_tool_calls_tests::max_tool_calls_per_prompt_codex_target ... ok +test profile::anthropic::tests::rejects_conflicting_dsflash_reasoning_effort ... ok +test profile::selection::tests::non_object_ephemeral_settings_reject ... ok +test profile::anthropic::issue81_parser_tests::openai_profile_resolution_binds_prompt_caching_setting ... ok +test model::tests::loopback_profile_without_credentials_resolves_an_empty_key ... ok +test model_api::settings::tests::codex_profile_effort_reaches_serialized_reasoning ... ok +test profile::tests::auth_key_name_is_a_named_provider_key_reference ... ok +test profile::selection::tests::api_mode_selects_and_removed_spellings_are_inert ... ok +test profile::selection::tests::absent_selectors_mean_provider_default ... ok +test profile::selection::tests::openai_responses_enabled_must_be_boolean ... ok +test profile::selection::tests::misspelled_selector_is_a_parse_error ... ok +test profile::tests::clear_thinking_is_typed_inert_model_param ... ok +test model::tests::file_profile_uses_local_keyfile_and_no_settings_fallback ... ok +test profile::tests::base_url_strict_rejection_table ... ok +test cli::tests::issue286_named_native_profile_preserves_host_settings ... ok +test profile::tests::codex_context_limit::codex_still_requires_an_explicit_context_limit ... ok +test profile::tests::codex_loop_detection_cannot_be_silently_ignored ... ok +test profile::tests::codex_context_limit::codex_rejects_invalid_context_limits ... ok +test profile::tests::codex_context_limit::codex_preserves_configured_positive_context_limits ... ok +test profile::tests::codex_task_timeouts::codex_host_task_timeouts_accept_non_magic_numbers_or_omission ... ok +test profile::tests::codex_max_turns_accepts_unlimited_and_any_positive_cap ... ok +test profile::tests::codex_task_timeouts::codex_host_task_timeouts_require_json_numbers_but_not_rust_ranges ... ok +test model::tests::marker_profiles_fail_on_their_first_structural_cause ... ok +test profile::tests::codex_task_timeouts::codex_owned_settings_remain_strict ... ok +test memory_profile::tests::sink_is_create_only_private_and_writes_complete_terminal_line ... ok +test profile::tests::codex_tools::codex_disabled_tools_reject_malformed_lists ... ok +test profile::tests::codex_tools::codex_disabled_tool_aliases_are_validated_and_equivalent ... ok +test profile::tests::codex_tools::openai_responses_rejects_dsflash_only_settings_under_any_name ... ok +test profile::tests::dsflash_effort::dsflash_effort_agreement_and_variant_scoping ... ok +test profile::tests::common_metadata_is_exact_and_name_independent ... ok +test memory_profile::tests::resolved_timeout_and_turn_elapsed_serialize_with_keys ... ok +test memory_profile::tests::profile_file_stays_bound_to_original_inode ... ok +test profile::tests::dsflash_effort::dsflash_effort_is_enum_validated_and_merged_into_the_discriminator ... ok +test profile::tests::dsflash_variant_is_structural_and_names_never_select ... ok +test profile::tests::ephemeral_and_modelparam_strict_type_table ... ok +test profile::tests::dsflash_marker_types_are_structural_not_name_gated ... ok +test profile::tests::host_settings::negative_timeout_sentinels_remove_profile_caps_but_not_host_safety_budgets ... ok +test profile::tests::keyfile_aliases_are_credentials_and_debug_is_redacted ... ok +test profile::tests::host_settings::host_keys_are_typed_for_every_provider_and_survive_resolution ... ok +test profile::tests::host_settings::zero_response_timeout_selects_native_default ... ok +test profile::tests::host_settings::full_native_named_astramedium_retains_model_context_and_effort ... ok +test profile::tests::host_settings::host_limits_reject_overflow_and_conflicts ... ok +test profile::tests::host_settings::every_host_key_rejects_malformed_values_for_every_provider ... ok +test profile::tests::loop_detection_rejects_true_and_non_booleans_on_the_chat_path ... ok +test profile::tests::model_identifier_rejects_empty_whitespace_and_controls ... ok +test profile::tests::numeric_strings::numeric_strings_are_coerced_for_sampling_settings ... ok +test profile::tests::numeric_strings::crusoeglm_fixture_numeric_string_top_p_loads ... ok +test profile::tests::openai_responses_settings::openai_responses_folds_kebab_max_tokens_model_params ... ok +test profile::tests::openai_responses_settings::openai_responses_folds_kebab_max_output_tokens_model_params ... ok +test profile::tests::numeric_strings::openai_responses_coerces_numeric_sampling_strings ... ok +test profile::tests::openai_responses_settings::openai_responses_rejects_disagreeing_max_output_model_params ... ok +test profile::tests::old_name_gated_dsflash_keys_are_unsupported_under_every_name ... ok +test profile::tests::openai_responses_settings::openai_responses_settings_are_strict_and_typed ... ok +test profile::tests::public_redacted_url_constructor_enforces_endpoint_policy ... ok +test profile::tests::reasoning_effort_enforces_prompt_note_cap ... ok +test profile::tests::openai_responses_settings::openai_responses_rejects_inapplicable_and_conflicting_settings ... ok +test profile::tests::parsed_profile_stores_the_neutral_selection ... ok +test profile::tests::unsupported_provider_coverage_uses_bedrock ... ok +test profile::tests::top_level_profile_shape_is_strict ... ok +test profile::tests::streaming_is_a_bounded_enum ... ok +test profile::tests::zai_anthropic_fixture_resolves_messages_target ... ok +test settings::tests::precedence_profile_overrides_user_file ... ok +test settings::tests::precedence_env_overrides_profile ... ok +test settings::tests::request_timeout_env_beats_profile ... ok +test settings::tests::request_timeout_profile_timeout_ms_wins_for_that_provider ... ok +test settings::tests::runtime_env_overrides_profile_for_turn_time ... ok +test context_eval::tests::generated_profile_installs_the_arm_runtime_config ... ok +test context_eval::tests_runner::prepare_emits_typed_settings_and_profile_without_base_url ... ok + +test result: ok. 85 passed; 0 failed; 0 ignored; 0 measured; 715 filtered out; finished in 0.07s + diff --git a/evalwork/results/pr-completion-20260920/issue79/vendor.exit b/evalwork/results/pr-completion-20260920/issue79/vendor.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/vendor.exit @@ -0,0 +1 @@ +0 diff --git a/evalwork/results/pr-completion-20260920/issue79/vendor.log b/evalwork/results/pr-completion-20260920/issue79/vendor.log new file mode 100644 index 00000000..58d66a8d --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/vendor.log @@ -0,0 +1,3 @@ +upstream archive and Git-object evidence verified +Serdes Responses Git snapshot evidence verified +vendor provenance checks ok diff --git a/evalwork/results/pr-completion-20260920/issue79/wire2.exit b/evalwork/results/pr-completion-20260920/issue79/wire2.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/wire2.exit @@ -0,0 +1 @@ +0 diff --git a/evalwork/results/pr-completion-20260920/issue79/wire2.log b/evalwork/results/pr-completion-20260920/issue79/wire2.log new file mode 100644 index 00000000..584b3e08 --- /dev/null +++ b/evalwork/results/pr-completion-20260920/issue79/wire2.log @@ -0,0 +1,9 @@ + Compiling llxprt-code-rs v0.1.0 (/Users/acoliver/projects/llxprt/agent/branch-4/worktrees/issue79) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s + Running tests/prompt_cache.rs (target/issue79-completion/debug/deps/prompt_cache-41231200c9a76af1) + +running 1 test +test exact_wire_round_and_turn_prefixes_and_cache_accounting ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.24s + diff --git a/scripts/verify-vendor-licenses.sh b/scripts/verify-vendor-licenses.sh index 27adca43..e868cc1b 100644 --- a/scripts/verify-vendor-licenses.sh +++ b/scripts/verify-vendor-licenses.sh @@ -51,7 +51,7 @@ for manifest in "${required_vendor[@]}"; do done patch_file=SERDES-AI-0.2.6.patch -expected_patch='79779b1463418b73ebb9eaab4d7e34d6e938339456d8e75252be539c139beffc' +expected_patch='e7d278fa6b9364645b975d794eacb088db1400a96c0c8f751a40a683d8961cdf' if [[ ! -f "$patch_file" ]]; then echo "missing reproducible vendor patch: $patch_file" >&2 fail=1 diff --git a/scripts/verify-vendor-provenance.sh b/scripts/verify-vendor-provenance.sh index 30c0d505..55323987 100644 --- a/scripts/verify-vendor-provenance.sh +++ b/scripts/verify-vendor-provenance.sh @@ -28,7 +28,7 @@ archives=( ) patch_digest="$(shasum -a 256 SERDES-AI-0.2.6.patch | awk '{print $1}')" -if [[ "$patch_digest" != "79779b1463418b73ebb9eaab4d7e34d6e938339456d8e75252be539c139beffc" ]]; then +if [[ "$patch_digest" != "e7d278fa6b9364645b975d794eacb088db1400a96c0c8f751a40a683d8961cdf" ]]; then echo "retained SerdesAI patch digest mismatch" >&2 exit 1 fi diff --git a/src/agent.rs b/src/agent.rs index 74d0346b..e858aca9 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -114,6 +114,7 @@ pub struct CodingAgent { /// Resolved output caps (issue 77): per-result shell/tool caps and the live /// per-turn tool-output bound. Defaults until the resolver overrides them. output_caps: OutputCaps, + shell_timeouts: (std::time::Duration, std::time::Duration), /// Digest admission floor handed to every read so a re-fetch recipe can name a /// window that stays under it (issue 125). digest_size_floor: usize, @@ -189,6 +190,10 @@ impl CodingAgent { max_tool_calls: None, turn_time_budget: None, output_caps: OutputCaps::default(), + shell_timeouts: ( + std::time::Duration::from_secs(120), + std::time::Duration::from_secs(120), + ), digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, request_timeout: None, max_rounds: MAX_TURN_ROUNDS, @@ -219,6 +224,10 @@ impl CodingAgent { max_tool_calls: None, turn_time_budget: None, output_caps: OutputCaps::default(), + shell_timeouts: ( + std::time::Duration::from_secs(120), + std::time::Duration::from_secs(120), + ), digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, request_timeout: None, max_rounds: MAX_TURN_ROUNDS, @@ -245,6 +254,10 @@ impl CodingAgent { max_tool_calls: None, turn_time_budget: None, output_caps: OutputCaps::default(), + shell_timeouts: ( + std::time::Duration::from_secs(120), + std::time::Duration::from_secs(120), + ), digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, request_timeout: None, max_rounds: MAX_TURN_ROUNDS, @@ -282,8 +295,17 @@ impl CodingAgent { self } - /// Override the resolved output caps (issue 77): per-result shell/tool caps and the - /// aggregate per-turn tool-output bound enforced by the turn loop. + /// Set host shell default and maximum budgets (independent of provider requests). + pub fn with_shell_timeouts( + mut self, + default: std::time::Duration, + max: std::time::Duration, + ) -> Self { + self.shell_timeouts = (default.min(max), max); + self + } + + /// Override per-result and aggregate tool-output caps. pub fn with_output_caps(mut self, caps: OutputCaps) -> CodingAgent { self.output_caps = caps; self diff --git a/src/agent/config.rs b/src/agent/config.rs index fff684fa..c650873a 100644 --- a/src/agent/config.rs +++ b/src/agent/config.rs @@ -1,4 +1,8 @@ /// Build the coding-agent system prompt. +/// +/// Cache invariant: this head depends only on immutable session configuration. +/// Never include timestamps, request IDs, remaining budgets, or per-round data. +/// The compiled HTTP prefix tests guard this together with tool serialization. pub fn coding_system_prompt( cwd: &std::path::Path, reasoning: &str, diff --git a/src/agent/helpers.rs b/src/agent/helpers.rs index feca7f97..7d3ef2cb 100644 --- a/src/agent/helpers.rs +++ b/src/agent/helpers.rs @@ -191,7 +191,8 @@ pub(super) fn tool_config_with_floor( digest_size_floor: agent.digest_size_floor, shell: crate::tools::ShellConfig { max_shell_output: agent.output_caps.shell, - max_shell_timeout: std::time::Duration::from_secs(120), + default_shell_timeout: agent.shell_timeouts.0, + max_shell_timeout: agent.shell_timeouts.1, allow_shell: shell_on, }, }) diff --git a/src/agent/tests.rs b/src/agent/tests.rs index e271c0c9..8d388e8d 100644 --- a/src/agent/tests.rs +++ b/src/agent/tests.rs @@ -774,3 +774,29 @@ fn with_request_timeout_emits_resolved_value() { .with_request_timeout(Some(timeout)); assert_eq!(agent.request_timeout, Some(timeout)); } + +#[test] +fn issue286_shell_budgets_reach_per_turn_tool_configuration() { + let root = tempfile::tempdir().unwrap(); + let profile = crate::profile::parse_profile_value( + &serde_json::from_str(include_str!( + "../../tests/fixtures/profiles/astramedium-native.json" + )) + .unwrap(), + "astramedium", + ) + .unwrap(); + let agent = CodingAgent::with_backend( + Box::new(MockBackend::new(Vec::new())), + root.path().to_path_buf(), + false, + ) + .with_shell_timeouts( + std::time::Duration::from_secs(profile.ephemeral.shell_default_timeout_seconds.unwrap()), + std::time::Duration::from_secs(profile.ephemeral.shell_max_timeout_seconds.unwrap()), + ); + let config = super::helpers::tool_config_with_floor(&agent, true).unwrap(); + assert_eq!(config.shell.default_shell_timeout.as_secs(), 2700); + assert_eq!(config.shell.max_shell_timeout.as_secs(), u32::MAX as u64); + assert!(config.shell.allow_shell); +} diff --git a/src/cli.rs b/src/cli.rs index 94cd7b95..d023cb3b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -749,6 +749,27 @@ mod tests { assert_eq!(args.max_turn_output, Some(20971520)); } + #[test] + fn issue286_named_native_profile_preserves_host_settings() { + let root = tempfile::tempdir_in("target").unwrap(); + std::fs::create_dir(root.path().join("profiles")).unwrap(); + let bytes = include_bytes!("../tests/fixtures/profiles/astramedium-native.json"); + let path = root.path().join("profiles/astramedium.json"); + std::fs::write(&path, bytes).unwrap(); + let args = Args::try_parse_from(["llxprt-code-rs", "--profile", "astramedium"]).unwrap(); + let profile = super::resolve_profile(&args, root.path()).unwrap(); + assert_eq!(profile.model, "gpt-6-astra"); + assert_eq!(profile.ephemeral.context_limit, Some(300000)); + assert_eq!(profile.ephemeral.timeout_ms, None); + assert_eq!(profile.ephemeral.shell_default_timeout_seconds, Some(2700)); + assert_eq!( + profile.ephemeral.shell_max_timeout_seconds, + Some(u32::MAX as u64) + ); + assert_eq!(profile.ephemeral.image_resize.max_long_edge, Some(2048)); + assert_eq!(std::fs::read(path).unwrap(), bytes); + } + #[test] fn print_config_resolves_and_exits_before_backend() { let args = Args::try_parse_from(["llxprt-code-rs", "--print-config"]).unwrap(); diff --git a/src/cli/run.rs b/src/cli/run.rs index aa455e6f..915539a1 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -143,6 +143,17 @@ fn build_agent( .with_max_rounds(constructed.max_rounds) .with_max_tool_calls(max_tool_calls) .with_turn_time(turn_time) + .with_shell_timeouts( + std::time::Duration::from_secs( + profile + .ephemeral + .shell_default_timeout_seconds + .unwrap_or(120), + ), + std::time::Duration::from_secs( + profile.ephemeral.shell_max_timeout_seconds.unwrap_or(120), + ), + ) .with_output_caps(resolved_output_caps(settings)) .with_request_timeout(Some(settings.budgets.request_timeout.value)) .with_profiler(profiler); diff --git a/src/model_api/cache_observation.rs b/src/model_api/cache_observation.rs new file mode 100644 index 00000000..d5ac5836 --- /dev/null +++ b/src/model_api/cache_observation.rs @@ -0,0 +1,234 @@ +//! Provider-reported prompt-cache accounting at the real request boundary. +//! +//! OpenAI input includes cached reads. Anthropic input excludes reads and writes. +//! Missing counters are never replaced with zero. The ratio covers only calls +//! reporting a complete input denominator and cached-read count, weighted by tokens. +use std::cell::RefCell; + +use crate::adapter::{ChatBackend, LlmUsage}; +use crate::tools::ToolSpec; +use serde::Serialize; +use serdes_ai::core::ModelRequest; + +#[derive(Clone, Copy)] +pub(super) enum InputAccounting { + Inclusive, + Anthropic, +} + +#[derive(Default, Serialize)] +struct RunCache { + calls: u64, + measured_calls: u64, + measured_input_tokens: u64, + measured_cached_tokens: u64, + hit_ratio: Option, +} + +#[derive(Serialize)] +struct Observation { + event: &'static str, + call: u64, + reported_input_tokens: Option, + cached_input_tokens: Option, + uncached_input_tokens: Option, + cache_creation_input_tokens: Option, + total_input_tokens: Option, + input_accounting: &'static str, +} + +impl InputAccounting { + fn observe(self, usage: &LlmUsage, call: u64) -> Observation { + let (total, uncached, accounting) = match self { + Self::Inclusive => ( + usage.request_tokens, + usage + .request_tokens + .zip(usage.cache_read_tokens) + .and_then(|(input, read)| input.checked_sub(read)), + "input_includes_cache_reads", + ), + Self::Anthropic => { + let total = usage + .request_tokens + .zip(usage.cache_creation_tokens) + .zip(usage.cache_read_tokens) + .and_then(|((input, creation), read)| { + input + .checked_add(creation) + .and_then(|n| n.checked_add(read)) + }); + ( + total, + usage + .request_tokens + .zip(usage.cache_creation_tokens) + .and_then(|(input, creation)| input.checked_add(creation)), + "input_excludes_cache_reads_and_creation", + ) + } + }; + Observation { + event: "prompt_cache_call", + call, + reported_input_tokens: usage.request_tokens, + cached_input_tokens: usage.cache_read_tokens, + uncached_input_tokens: uncached, + cache_creation_input_tokens: usage.cache_creation_tokens, + total_input_tokens: total, + input_accounting: accounting, + } + } +} + +impl RunCache { + fn record(&mut self, accounting: InputAccounting, usage: &LlmUsage) -> Observation { + self.calls += 1; + let observation = accounting.observe(usage, self.calls); + if let Some((input, read)) = observation + .total_input_tokens + .zip(observation.cached_input_tokens) + .filter(|(input, read)| read <= input) + { + self.measured_calls += 1; + self.measured_input_tokens = self + .measured_input_tokens + .checked_add(input) + .expect("run input overflow"); + self.measured_cached_tokens = self + .measured_cached_tokens + .checked_add(read) + .expect("run cache overflow"); + self.hit_ratio = (self.measured_input_tokens != 0) + .then(|| self.measured_cached_tokens as f64 / self.measured_input_tokens as f64); + } + observation + } +} + +pub(super) struct ObservedBackend { + inner: Box, + accounting: InputAccounting, + run: RefCell, +} + +impl ObservedBackend { + pub(super) fn new(inner: Box, accounting: InputAccounting) -> Self { + Self { + inner, + accounting, + run: RefCell::new(RunCache::default()), + } + } +} + +impl ChatBackend for ObservedBackend { + fn request<'a>( + &'a self, + requests: &'a [ModelRequest], + tools: &'a [ToolSpec], + ) -> crate::adapter::ModelFuture<'a> { + Box::pin(async move { + let result = self.inner.request(requests, tools).await?; + let mut run = self.run.borrow_mut(); + let observation = run.record(self.accounting, &result.usage); + // Stderr is deliberately separate from the exactly-one-object stdout contract. + eprintln!( + "{}", + serde_json::to_string(&observation).expect("cache observation serialization") + ); + eprintln!( + "{}", + serde_json::json!({"event": "prompt_cache_run", "usage": &*run}) + ); + Ok(result) + }) + } + + fn request_calls(&self) -> usize { + self.inner.request_calls() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absent_zero_and_mixed_usage_preserve_denominators() { + let mut run = RunCache::default(); + let absent = run.record(InputAccounting::Inclusive, &LlmUsage::default()); + assert_eq!(absent.uncached_input_tokens, None); + assert_eq!(run.hit_ratio, None); + run.record( + InputAccounting::Inclusive, + &LlmUsage { + request_tokens: Some(0), + cache_read_tokens: Some(0), + ..Default::default() + }, + ); + assert_eq!(run.hit_ratio, None); + let first = run.record( + InputAccounting::Inclusive, + &LlmUsage { + request_tokens: Some(100), + cache_read_tokens: Some(60), + ..Default::default() + }, + ); + assert_eq!(first.uncached_input_tokens, Some(40)); + let second = run.record( + InputAccounting::Anthropic, + &LlmUsage { + request_tokens: Some(20), + cache_creation_tokens: Some(130), + cache_read_tokens: Some(50), + ..Default::default() + }, + ); + assert_eq!(second.total_input_tokens, Some(200)); + assert_eq!(second.uncached_input_tokens, Some(150)); + assert_eq!(run.hit_ratio, Some(110.0 / 300.0)); + assert_eq!(run.calls, 4); + assert_eq!(run.measured_calls, 3); + let missing = run.record( + InputAccounting::Anthropic, + &LlmUsage { + request_tokens: Some(20), + cache_read_tokens: Some(50), + ..Default::default() + }, + ); + assert_eq!(missing.total_input_tokens, None); + assert_eq!(run.measured_calls, 3); + } + + #[test] + fn inconsistent_external_counters_do_not_panic_or_pollute_ratio() { + let mut run = RunCache::default(); + let inconsistent = run.record( + InputAccounting::Inclusive, + &LlmUsage { + request_tokens: Some(1), + cache_read_tokens: Some(2), + ..Default::default() + }, + ); + assert_eq!(inconsistent.cached_input_tokens, Some(2)); + assert_eq!(inconsistent.uncached_input_tokens, None); + let overflow = run.record( + InputAccounting::Anthropic, + &LlmUsage { + request_tokens: Some(u64::MAX), + cache_creation_tokens: Some(1), + cache_read_tokens: Some(1), + ..Default::default() + }, + ); + assert_eq!(overflow.total_input_tokens, None); + assert_eq!(overflow.uncached_input_tokens, None); + assert_eq!(run.measured_calls, 0); + assert_eq!(run.hit_ratio, None); + } +} diff --git a/src/model_api/interpret.rs b/src/model_api/interpret.rs index 76751590..6a80b709 100644 --- a/src/model_api/interpret.rs +++ b/src/model_api/interpret.rs @@ -156,7 +156,7 @@ fn anthropic_draft(settings: &AnthropicSettings) -> AnthropicSettingsDraft { } fn codex_draft(settings: &CodexResponsesSettings, model: &str) -> CodexResponsesSettingsDraft { - CodexResponsesSettingsDraft::new(model.to_string(), settings.reasoning_enabled) + CodexResponsesSettingsDraft::new(model.to_string(), settings.reasoning_effort.clone()) } fn openai_responses_draft( diff --git a/src/model_api/mod.rs b/src/model_api/mod.rs index eabdcbaf..74740342 100644 --- a/src/model_api/mod.rs +++ b/src/model_api/mod.rs @@ -1,4 +1,5 @@ mod anthropic_backend; +mod cache_observation; pub(crate) mod credentials; pub(crate) mod dependencies; pub(crate) mod interpret; diff --git a/src/model_api/registry.rs b/src/model_api/registry.rs index 5b32642e..260b60db 100644 --- a/src/model_api/registry.rs +++ b/src/model_api/registry.rs @@ -38,9 +38,10 @@ pub(crate) fn construct_backend( .iter() .find(|registration| registration.target == resolved.target) .ok_or_else(|| "selected model API is not registered".to_string())?; - match registration.constructor { + let mut constructed = match registration.constructor { ConstructorKind::OpenAiChat => construct_chat( profile, + session_id, dependencies, profile_from_file, allow_insecure_http, @@ -60,8 +61,20 @@ pub(crate) fn construct_backend( profile_from_file, allow_insecure_http, ), - ConstructorKind::CodexResponses => construct_codex(profile, &resolved, dependencies), - } + ConstructorKind::CodexResponses => { + construct_codex(profile, &resolved, session_id, dependencies) + } + }?; + let accounting = if matches!(registration.constructor, ConstructorKind::AnthropicMessages) { + super::cache_observation::InputAccounting::Anthropic + } else { + super::cache_observation::InputAccounting::Inclusive + }; + constructed.backend = Box::new(super::cache_observation::ObservedBackend::new( + constructed.backend, + accounting, + )); + Ok(constructed) } /// The `modelParams` acceptance policy (issue 64). @@ -153,11 +166,12 @@ fn resolved_timeout(profile: &Profile) -> std::time::Duration { fn construct_chat( profile: &Profile, + session_id: &crate::session::SessionId, dependencies: &RuntimeDependencies, profile_from_file: bool, allow_insecure_http: bool, ) -> Result { - let config = ModelConfig::from_profile_in( + let mut config = ModelConfig::from_profile_in( profile, profile_from_file, allow_insecure_http, @@ -173,6 +187,20 @@ fn construct_chat( crate::limits::validate_timeout(config.timeout)?; let secret_values = config.secret_values(); let context_limit = config.context_limit; + let forwarded = &mut config + .model_params + .get_or_insert_with(Default::default) + .forwarded; + // The session owns this routing field, not arbitrary model parameters. + forwarded.remove("prompt_cache_key"); + if profile.ephemeral.prompt_caching + != Some(crate::profile::provider_settings::PromptCachingSetting::Off) + { + forwarded.insert( + "prompt_cache_key".to_string(), + serde_json::json!(session_id.id), + ); + } let backend = make_adapter(&config).map_err(|error| error.to_string())?; let max_rounds = resolve_max_rounds(profile)?; Ok(ConstructedBackend { @@ -413,6 +441,7 @@ fn validate_anthropic_settings(profile: &Profile) -> Result<(), String> { fn construct_codex( profile: &Profile, resolved: &crate::model_api::interpret::ResolvedProfile, + session_id: &crate::session::SessionId, dependencies: &RuntimeDependencies, ) -> Result { let draft = resolved @@ -432,6 +461,11 @@ fn construct_codex( let user_agent = format!("llxprt-code-rs/{}", env!("CARGO_PKG_VERSION")); let mut model = OpenResponsesModel::new(draft.model(), draft.endpoint().responses_url()) .codex_http() + .with_prompt_cache_key( + (profile.ephemeral.prompt_caching + != Some(crate::profile::provider_settings::PromptCachingSetting::Off)) + .then(|| session_id.id.clone()), + ) .bearer(credential.access_token()) .header("chatgpt-account-id", credential.account_id()) .header("OpenAI-Beta", CODEX_RESPONSES_BETA) diff --git a/src/model_api/registry/tests.rs b/src/model_api/registry/tests.rs index 78e076d8..7368011c 100644 --- a/src/model_api/registry/tests.rs +++ b/src/model_api/registry/tests.rs @@ -95,7 +95,7 @@ impl CredentialSource for InMemorySource { fn codex_transport_identity_is_fixed() { let draft = crate::model_api::settings::CodexResponsesSettingsDraft::new( "gpt-5.6-sol".to_string(), - true, + Some("high".to_string()), ); assert_eq!( draft.endpoint().responses_url(), @@ -468,12 +468,12 @@ fn request_timeout_resolved_policy_defaults_to_900s_for_every_provider_path() { fn request_timeout_codex_consumes_provider_timeout_data() { // A provider `timeoutMs` is data at the resolver and flows onto the profile, so // the codex path honors it now (previously it ignored the field). - let value: serde_json::Value = serde_json::from_str(include_str!( + let mut value: serde_json::Value = serde_json::from_str(include_str!( "../../../tests/fixtures/profiles/gpt56solhigh.json" )) .unwrap(); - let mut profile = crate::profile::parse_profile_value(&value, "gpt56solhigh").unwrap(); - profile.ephemeral.timeout_ms = Some(120_000); + value["ephemeralSettings"]["stream-first-response-timeout-ms"] = serde_json::json!(120_000); + let profile = crate::profile::parse_profile_value(&value, "gpt56solhigh").unwrap(); assert_eq!( resolved_timeout(&profile), std::time::Duration::from_secs(120) @@ -733,3 +733,17 @@ fn known_model_mode_accepts_registry_keys_and_refuses_unknown() { .unwrap_err(); assert!(err.contains("definitely_not_a_param"), "{err}"); } + +#[test] +fn issue286_codex_negative_first_response_budget_uses_native_request_bound() { + let value = serde_json::from_str(include_str!( + "../../../tests/fixtures/profiles/astramedium-native.json" + )) + .unwrap(); + let profile = crate::profile::parse_profile_value(&value, "astramedium").unwrap(); + assert_eq!(resolved_timeout(&profile), DEFAULT_REQUEST_TIMEOUT); + assert_eq!( + codex_model_settings(&profile).timeout, + Some(DEFAULT_REQUEST_TIMEOUT) + ); +} diff --git a/src/model_api/responses_backend.rs b/src/model_api/responses_backend.rs index 39eb20d9..ff201d2c 100644 --- a/src/model_api/responses_backend.rs +++ b/src/model_api/responses_backend.rs @@ -100,6 +100,9 @@ impl ChatBackend for ResponsesBackend { } } +#[cfg(test)] +mod cache_tests; + #[cfg(test)] mod tests { use super::*; @@ -145,10 +148,19 @@ mod tests { }); let model = OpenResponsesModel::new("test-model", format!("ws://127.0.0.1:{port}")); + let mut value: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/task-profile/astra-headless.json" + )) + .unwrap(); + value["ephemeralSettings"]["stream-first-response-timeout-ms"] = serde_json::json!(1000); + let profile = crate::profile::parse_profile_value(&value, "astramedium").unwrap(); let backend = ResponsesBackend::new( model, ModelSettings { - timeout: Some(std::time::Duration::from_secs(1)), + timeout: profile + .ephemeral + .timeout_ms + .map(std::time::Duration::from_millis), ..Default::default() }, ); @@ -166,7 +178,7 @@ mod tests { } /// Offset just past the CRLF CRLF header/body separator. - fn find_body_start(request: &[u8]) -> Option { + pub(super) fn find_body_start(request: &[u8]) -> Option { request .windows(4) .position(|window| window == b"\r\n\r\n") @@ -202,6 +214,7 @@ mod tests { format!("http://127.0.0.1:{port}/responses"), ) .codex_http() + .with_prompt_cache_key(Some("loopback-session".to_string())) .bearer("loopback-codex-key"); let backend = ResponsesBackend::new( model, @@ -236,6 +249,8 @@ mod tests { let bodies: Vec = bodies_rx.iter().collect(); assert_codex_wire_contract(&bodies); + assert_eq!(first.usage.cache_read_tokens, Some(6)); + assert_eq!(second.usage.cache_read_tokens, Some(6)); assert!( first.text.contains("codex turn one"), "folded output missing: {first:?}" @@ -249,7 +264,7 @@ mod tests { /// Reads one full HTTP request (headers plus a content-length body) off /// the accepted codex connection. - fn read_codex_request(stream: &mut std::net::TcpStream) -> Vec { + pub(super) fn read_codex_request(stream: &mut std::net::TcpStream) -> Vec { use std::io::Read as _; stream .set_read_timeout(Some(std::time::Duration::from_secs(10))) @@ -279,7 +294,7 @@ mod tests { /// real codex backend: the terminal response event, then EOF, no /// `[DONE]` marker. Round 1 keeps the marker so both terminations stay /// covered. - fn codex_turn_sse_payload(round: usize) -> String { + pub(super) fn codex_turn_sse_payload(round: usize) -> String { let turn = if round == 0 { "one" } else { "two" }; let response_id = format!("resp_loopback_{round}"); let mut object = serdes_ai_responses::types::ResponseObject::in_progress( @@ -290,11 +305,13 @@ mod tests { .unwrap(), ); object.status = serdes_ai_responses::types::ResponseStatus::Completed; - object.usage = Some(serdes_ai_responses::types::ResponseUsage { - input_tokens: Some(11), - output_tokens: Some(7), - total_tokens: Some(18), - }); + object.usage = Some( + serde_json::from_value(serde_json::json!({ + "input_tokens": 11, "output_tokens": 7, "total_tokens": 18, + "input_tokens_details": {"cached_tokens": 6} + })) + .unwrap(), + ); let created = serdes_ai_responses::types::StreamEvent::ResponseCreated { sequence_number: 0, response: object.clone(), @@ -368,6 +385,7 @@ mod tests { assert_eq!(bodies.len(), 2); for body in bodies { assert_eq!(body["store"], false, "codex must never store"); + assert_eq!(body["prompt_cache_key"], "loopback-session"); assert_eq!(body["stream"], true, "codex must stream over SSE"); assert!( body.get("max_output_tokens").is_none(), diff --git a/src/model_api/responses_backend/cache_tests.rs b/src/model_api/responses_backend/cache_tests.rs new file mode 100644 index 00000000..d573f807 --- /dev/null +++ b/src/model_api/responses_backend/cache_tests.rs @@ -0,0 +1,135 @@ +//! Codex's fixed production endpoint cannot be redirected by a CLI profile. +//! Exercise its real HTTP backend and assembler, without adding a production override. +use super::*; +use serdes_ai::core::messages::ModelRequestPart; +use std::io::Write; + +fn raw_field<'a>(body: &'a str, name: &str) -> &'a str { + let start = body.find(&format!("\"{name}\":")).unwrap() + name.len() + 3; + let rest = &body[start..]; + let mut stream = serde_json::Deserializer::from_str(rest).into_iter::(); + stream.next().unwrap().unwrap(); + &rest[..stream.byte_offset()] +} + +#[test] +fn codex_exact_http_head_rounds_turns_off_and_missing_cache_usage() { + for enabled in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for round in 0..4 { + let (mut socket, _) = listener.accept().unwrap(); + let bytes = super::tests::read_codex_request(&mut socket); + let start = super::tests::find_body_start(&bytes).unwrap(); + bodies.push(String::from_utf8(bytes[start..].to_vec()).unwrap()); + let mut payload = super::tests::codex_turn_sse_payload(round); + if round == 0 { + payload = payload.replace("\"cached_tokens\":6", "\"other_detail\":6"); + } + socket.write_all(payload.as_bytes()).unwrap(); + } + bodies + }); + let mut history = ModelRequest::default(); + history.add_system_prompt(crate::agent::coding_system_prompt( + std::path::Path::new("/fixture"), + "", + false, + Some(8), + )); + history.add_user_prompt("first turn"); + let retained = tempfile::NamedTempFile::new().unwrap(); + for turn in 0..2 { + std::fs::write(retained.path(), serde_json::to_vec(&history).unwrap()).unwrap(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "model_api::responses_backend::cache_tests::codex_cache_child_turn", + "--nocapture", + ]) + .env("ISSUE79_CHILD_HISTORY", retained.path()) + .env("ISSUE79_CHILD_ADDRESS", address.to_string()) + .env("ISSUE79_CHILD_ENABLED", enabled.to_string()) + .env("ISSUE79_CHILD_TURN", turn.to_string()) + .output() + .unwrap(); + assert!( + child.status.success(), + "{}", + String::from_utf8_lossy(&child.stderr) + ); + assert!(String::from_utf8_lossy(&child.stdout).contains("1 passed")); + history = serde_json::from_slice(&std::fs::read(retained.path()).unwrap()).unwrap(); + } + let bodies = server.join().unwrap(); + for (index, body) in bodies.iter().enumerate() { + assert_eq!(raw_field(body, "tools"), raw_field(&bodies[0], "tools")); + assert_eq!( + raw_field(body, "instructions"), + raw_field(&bodies[0], "instructions") + ); + let value: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!( + value.get("prompt_cache_key"), + enabled.then_some(&serde_json::json!("session-fixture")) + ); + assert!(value.get("previous_response_id").is_none()); + if index > 0 { + let previous = raw_field(&bodies[index - 1], "input"); + assert!(raw_field(body, "input").starts_with(&previous[..previous.len() - 1])); + } + } + let instructions = raw_field(&bodies[0], "instructions"); + assert_ne!( + instructions, + instructions.replace("You", "Timestamp: 1. You") + ); + } +} + +// Invoked by the parent fixture as a fresh OS process on each turn. No production +// endpoint override: the loopback endpoint is restricted to this test executable. +#[test] +fn codex_cache_child_turn() { + let Ok(path) = std::env::var("ISSUE79_CHILD_HISTORY") else { + return; + }; + let mut history: ModelRequest = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let address = std::env::var("ISSUE79_CHILD_ADDRESS").unwrap(); + let enabled = std::env::var("ISSUE79_CHILD_ENABLED").unwrap() == "true"; + let turn: usize = std::env::var("ISSUE79_CHILD_TURN") + .unwrap() + .parse() + .unwrap(); + let tools = crate::tools::tool_specs(false); + let model = OpenResponsesModel::new("fixture", format!("http://{address}/responses")) + .codex_http() + .with_prompt_cache_key(enabled.then(|| "session-fixture".to_string())); + let backend = ResponsesBackend::new( + model, + ModelSettings { + timeout: Some(std::time::Duration::from_secs(10)), + ..Default::default() + }, + ); + for round in 0..2 { + let reply = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(backend.request(&[history.clone()], &tools)) + .unwrap(); + let expected = (turn != 0 || round != 0).then_some(6); + assert_eq!(reply.usage.cache_read_tokens, expected); + history.parts.push(ModelRequestPart::ToolReturn( + serdes_ai::core::ToolReturnPart::new("read_file", "stable fixture contents") + .with_tool_call_id(format!("fixture-{turn}-{round}")), + )); + history.parts.push(ModelRequestPart::UserPrompt( + serdes_ai::core::messages::UserPromptPart::new(format!("appended {turn}/{round}")), + )); + } + std::fs::write(path, serde_json::to_vec(&history).unwrap()).unwrap(); +} diff --git a/src/model_api/settings.rs b/src/model_api/settings.rs index 22e01ab9..e34a21a7 100644 --- a/src/model_api/settings.rs +++ b/src/model_api/settings.rs @@ -33,15 +33,15 @@ pub(crate) struct AnthropicSettingsDraft { pub(crate) struct CodexResponsesSettingsDraft { model: String, endpoint: CodexEndpointIdentity, - reasoning_enabled: bool, + reasoning_effort: Option, } impl CodexResponsesSettingsDraft { - pub(crate) fn new(model: String, reasoning_enabled: bool) -> Self { + pub(crate) fn new(model: String, reasoning_effort: Option) -> Self { Self { model, endpoint: CodexEndpointIdentity::Production, - reasoning_enabled, + reasoning_effort, } } @@ -54,10 +54,12 @@ impl CodexResponsesSettingsDraft { } pub(crate) fn responses_reasoning(&self) -> Option { - self.reasoning_enabled.then(|| ReasoningSettings { - effort: Some("high".to_string()), - summary: Some(serde_json::Value::String("auto".to_string())), - }) + self.reasoning_effort + .as_ref() + .map(|effort| ReasoningSettings { + effort: Some(effort.clone()), + summary: Some(serde_json::Value::String("auto".to_string())), + }) } } diff --git a/src/model_api/settings/tests.rs b/src/model_api/settings/tests.rs index e0ecbbbf..b405b753 100644 --- a/src/model_api/settings/tests.rs +++ b/src/model_api/settings/tests.rs @@ -1,8 +1,47 @@ use super::*; +#[test] +fn codex_profile_effort_reaches_serialized_reasoning() { + let base: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/task-profile/astra-headless.json" + )) + .unwrap(); + for effort in ["low", "medium", "high"] { + let mut value = base.clone(); + value["ephemeralSettings"]["reasoning.effort"] = serde_json::json!(effort); + let profile = crate::profile::parse_profile_value(&value, "astra").unwrap(); + let resolved = crate::model_api::interpret::ResolvedProfile::interpret(&profile).unwrap(); + let reasoning = resolved.codex.unwrap().responses_reasoning().unwrap(); + assert_eq!( + serde_json::to_value(reasoning).unwrap(), + serde_json::json!({ + "effort": effort, "summary": "auto" + }) + ); + } + for effort in [ + serde_json::json!("invalid"), + serde_json::json!(42), + serde_json::json!(null), + ] { + let mut value = base.clone(); + value["ephemeralSettings"]["reasoning.effort"] = effort; + assert!(crate::profile::parse_profile_value(&value, "astra").is_err()); + } + let mut disabled = base; + let settings = disabled["ephemeralSettings"].as_object_mut().unwrap(); + settings.insert("reasoning.enabled".into(), serde_json::json!(false)); + settings.remove("reasoning.effort"); + settings.remove("reasoning.summary"); + let profile = crate::profile::parse_profile_value(&disabled, "astra").unwrap(); + let resolved = crate::model_api::interpret::ResolvedProfile::interpret(&profile).unwrap(); + assert!(resolved.codex.unwrap().responses_reasoning().is_none()); +} + #[test] fn production_endpoint_identity_is_exact() { - let draft = CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), true); + let draft = + CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), Some("high".to_string())); assert_eq!( draft.endpoint().responses_url(), "https://chatgpt.com/backend-api/codex/responses" @@ -12,7 +51,8 @@ fn production_endpoint_identity_is_exact() { #[test] fn enabled_reasoning_maps_to_tested_responses_shape() { - let draft = CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), true); + let draft = + CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), Some("high".to_string())); let reasoning = draft.responses_reasoning().unwrap(); assert_eq!(reasoning.effort.as_deref(), Some("high")); assert_eq!(reasoning.summary, Some(serde_json::json!("auto"))); @@ -20,7 +60,7 @@ fn enabled_reasoning_maps_to_tested_responses_shape() { #[test] fn disabled_reasoning_omits_responses_reasoning() { - let draft = CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), false); + let draft = CodexResponsesSettingsDraft::new("gpt-5.6-sol".to_string(), None); assert!(draft.responses_reasoning().is_none()); } diff --git a/src/profile.rs b/src/profile.rs index 9fbf83f4..9324d022 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -11,6 +11,7 @@ mod anthropic; mod chat; mod codex; +mod host; mod openai_responses; mod parsing; /// Provider-neutral settings carriers: read by `model_api` when it interprets a @@ -275,9 +276,24 @@ pub fn resolve_max_tool_calls(cli: Option, profile: MaxToolCalls) -> Option } } -/// Transport + request settings from a profile's `ephemeralSettings`. +/// Host-owned image limits, retained but not applied by the text-only native runtime. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImageResizeSettings { + pub max_long_edge: Option, + pub max_short_edge: Option, + pub max_pixels: Option, +} + +/// Host and transport settings from a profile's `ephemeralSettings`. #[derive(Clone, Default)] pub struct EphemeralSettings { + /// Session prompt-cache routing, enabled when absent. + pub prompt_caching: Option, + /// Host shell budgets; seconds, strictly positive. + pub shell_default_timeout_seconds: Option, + pub shell_max_timeout_seconds: Option, + /// Validated host image policy. Dormant: native requests are text-only. + pub image_resize: ImageResizeSettings, pub base_url: Option, /// Redacted keyfile rendering (basename only) for `Debug`/errors. pub auth_keyfile: Option, @@ -352,6 +368,12 @@ impl std::fmt::Debug for EphemeralSettings { .field("max_tool_calls_per_prompt", &self.max_tool_calls_per_prompt) .field("loop_detection_enabled", &self.loop_detection_enabled) .field("timeout_ms", &self.timeout_ms) + .field( + "shell_default_timeout_seconds", + &self.shell_default_timeout_seconds, + ) + .field("shell_max_timeout_seconds", &self.shell_max_timeout_seconds) + .field("image_resize", &self.image_resize) .field("flags", &self.flags) .field("prompt_note_keys", &prompt_note_keys) .field("unsupported", &self.unsupported) diff --git a/src/profile/anthropic.rs b/src/profile/anthropic.rs index 164460d8..17be7976 100644 --- a/src/profile/anthropic.rs +++ b/src/profile/anthropic.rs @@ -53,7 +53,7 @@ mod issue81_parser_tests { use crate::profile::parse_profile_value; #[test] - fn openai_profile_resolution_rejects_anthropic_prompt_caching_setting() { + fn openai_profile_resolution_binds_prompt_caching_setting() { let json = r#"{ "provider": "openai", "model": "gpt-loopback", @@ -66,22 +66,19 @@ mod issue81_parser_tests { let value: serde_json::Value = serde_json::from_str(json).unwrap(); let profile = parse_profile_value(&value, "ordinary-openai").unwrap(); - assert!(profile + assert_eq!( + profile.ephemeral.prompt_caching, + Some(super::super::provider_settings::PromptCachingSetting::Off) + ); + assert!(!profile .ephemeral .unsupported .iter() .any(|setting| setting == "prompt-caching")); let config_root = tempfile::tempdir().unwrap(); - let error = - crate::model::ModelConfig::from_profile_in(&profile, false, true, config_root.path()) - .unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported profile setting(s): prompt-caching"), - "unexpected resolution error: {error}" - ); + crate::model::ModelConfig::from_profile_in(&profile, false, true, config_root.path()) + .expect("issue 79 binds OpenAI Chat prompt caching"); } #[test] diff --git a/src/profile/chat.rs b/src/profile/chat.rs index a247de65..d4ab6f57 100644 --- a/src/profile/chat.rs +++ b/src/profile/chat.rs @@ -163,6 +163,7 @@ pub(super) fn parse_ephemeral( unsupported.push(key.clone()); } } + super::host::validate(&settings, name)?; settings.unsupported = unsupported; Ok(settings) } @@ -173,6 +174,19 @@ fn parse_ephemeral_entry( value: &serde_json::Value, name: &str, ) -> Result { + if key == "prompt-caching" { + let raw = value + .as_str() + .ok_or_else(|| format!("profile {name:?}: 'prompt-caching' must be a string"))?; + settings.prompt_caching = Some( + super::provider_settings::PromptCachingSetting::openai_responses(Some(raw)) + .map_err(|error| format!("profile {name:?}: 'prompt-caching' {error}"))?, + ); + return Ok(true); + } + if super::host::parse(settings, key, value, name)? { + return Ok(true); + } if parse_ephemeral_primary(settings, key, value, name)? { return Ok(true); } @@ -237,7 +251,6 @@ fn parse_ephemeral_primary( settings.max_tool_calls_per_prompt = MaxToolCalls::parse(value, name)?; } "context-limit" | "contextLimit" => settings.context_limit = Some(nonnegative()?), - "stream-first-response-timeout-ms" => settings.timeout_ms = Some(nonnegative()?), "apiMode" | "openaiResponsesEnabled" => {} "base-url" | "baseUrl" | "baseURL" => { let raw = required_string(value, name, key)?; diff --git a/src/profile/codex.rs b/src/profile/codex.rs index 5f2ee2e9..6252d83f 100644 --- a/src/profile/codex.rs +++ b/src/profile/codex.rs @@ -5,7 +5,6 @@ use super::provider_settings::CodexResponsesSettings; use super::{EphemeralSettings, MaxToolCalls, ModelParams}; const CODEX_PROFILE_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex"; -const CODEX_CONTEXT_LIMIT: u64 = 262_144; pub(super) struct ParsedCodexSettings { pub(super) ephemeral: EphemeralSettings, @@ -16,11 +15,25 @@ pub(super) struct ParsedCodexSettings { pub(super) fn parse(obj: &Map, name: &str) -> Result { let ephemeral = object_field(obj, "ephemeralSettings", name)?; let model_params = object_field(obj, "modelParams", name)?; - let mut settings = EphemeralSettings::default(); + let mut settings = EphemeralSettings { + prompt_caching: Some( + super::provider_settings::PromptCachingSetting::openai_responses(optional_string( + &ephemeral, + "prompt-caching", + name, + )?) + .map_err(|error| format!("profile {name:?}: 'prompt-caching' {error}"))?, + ), + ..Default::default() + }; + for (key, value) in &ephemeral { + super::host::parse(&mut settings, key, value, name)?; + } + super::host::validate(&settings, name)?; parse_endpoint(&ephemeral, name, &mut settings)?; parse_common(&ephemeral, name, &mut settings)?; - let reasoning_enabled = parse_reasoning(&ephemeral, name)?; + let reasoning_effort = parse_reasoning(&ephemeral, name)?; validate_provider_constraints(&ephemeral, name)?; reject_unknown_ephemeral(&ephemeral, name)?; let model_params = parse_model_params(&model_params, name, &mut settings)?; @@ -28,7 +41,7 @@ pub(super) fn parse(obj: &Map, name: &str) -> Result Result<(), String> { let context_limit = required_u64(map, "context-limit", name)?; - if context_limit != CODEX_CONTEXT_LIMIT { + if context_limit == 0 { return Err(format!( - "profile {name:?}: Codex 'context-limit' must be {CODEX_CONTEXT_LIMIT}" + "profile {name:?}: 'context-limit' must be a positive integer" )); } settings.context_limit = Some(context_limit); @@ -97,7 +110,7 @@ fn parse_common( Ok(()) } -fn parse_reasoning(map: &Map, name: &str) -> Result { +fn parse_reasoning(map: &Map, name: &str) -> Result, String> { let enabled = required_bool(map, "reasoning.enabled", name)?; if !enabled { if map.contains_key("reasoning.effort") || map.contains_key("reasoning.summary") { @@ -105,11 +118,16 @@ fn parse_reasoning(map: &Map, name: &str) -> Result "profile {name:?}: disabled Codex reasoning must omit effort and summary" )); } - return Ok(false); + return Ok(None); + } + let effort = required_string(map, "reasoning.effort", name)?; + if !matches!(effort, "low" | "medium" | "high") { + return Err(format!( + "profile {name:?}: reasoning effort must be low, medium, or high" + )); } - require_exact_string(map, "reasoning.effort", "high", name)?; require_exact_string(map, "reasoning.summary", "auto", name)?; - Ok(true) + Ok(Some(effort.to_string())) } fn validate_provider_constraints(map: &Map, name: &str) -> Result<(), String> { @@ -256,7 +274,7 @@ fn reject_unknown_ephemeral(map: &Map, name: &str) -> Result<(), ]; if let Some(key) = btree(map) .keys() - .find(|key| !ALLOWED.contains(&key.as_str())) + .find(|key| !ALLOWED.contains(&key.as_str()) && !super::host::KEYS.contains(&key.as_str())) { return Err(format!( "profile {name:?}: unsupported Codex setting '{key}'" diff --git a/src/profile/host.rs b/src/profile/host.rs new file mode 100644 index 00000000..92a14569 --- /dev/null +++ b/src/profile/host.rs @@ -0,0 +1,98 @@ +//! Host policy shared by every provider grammar. No provider wire parameters. +use super::EphemeralSettings; +use serde_json::Value; + +pub(super) const KEYS: &[&str] = &[ + "shell-default-timeout-seconds", + "shell-max-timeout-seconds", + "stream-first-response-timeout-ms", + "image-resize.maxLongEdge", + "image-resize.maxShortEdge", + "image-resize.maxPixels", +]; + +pub(super) fn parse( + settings: &mut EphemeralSettings, + key: &str, + value: &Value, + name: &str, +) -> Result { + if !KEYS.contains(&key) { + return Ok(false); + } + // -1 means no profile-imposed budget. The host's finite safety bounds remain. + if value.as_i64() == Some(-1) { + match key { + "shell-max-timeout-seconds" => { + settings.shell_max_timeout_seconds = Some(u32::MAX as u64); + return Ok(true); + } + "stream-first-response-timeout-ms" => { + settings.timeout_ms = None; + return Ok(true); + } + _ => {} + } + } + // Zero selects the native default budget, not an unbounded request. + if key == "stream-first-response-timeout-ms" && value.as_u64() == Some(0) { + settings.timeout_ms = None; + return Ok(true); + } + let n = value + .as_u64() + .filter(|n| *n > 0) + .ok_or_else(|| format!("profile {name:?}: '{key}' must be a positive integer"))?; + match key { + "shell-default-timeout-seconds" | "shell-max-timeout-seconds" => { + if n > u32::MAX as u64 { + return Err(format!( + "profile {name:?}: '{key}' exceeds supported seconds" + )); + } + if key == "shell-default-timeout-seconds" { + settings.shell_default_timeout_seconds = Some(n); + } else { + settings.shell_max_timeout_seconds = Some(n); + } + } + "stream-first-response-timeout-ms" => { + // Native backends perform non-streaming requests: bound the entire response. + crate::limits::validate_timeout(Some(std::time::Duration::from_millis(n)))?; + settings.timeout_ms = Some(n); + } + "image-resize.maxLongEdge" | "image-resize.maxShortEdge" => { + let n = u32::try_from(n) + .map_err(|_| format!("profile {name:?}: '{key}' exceeds image dimension range"))?; + if key == "image-resize.maxLongEdge" { + settings.image_resize.max_long_edge = Some(n); + } else { + settings.image_resize.max_short_edge = Some(n); + } + } + "image-resize.maxPixels" => settings.image_resize.max_pixels = Some(n), + _ => unreachable!(), + } + Ok(true) +} + +pub(super) fn validate(settings: &EphemeralSettings, name: &str) -> Result<(), String> { + if settings.shell_default_timeout_seconds.unwrap_or(120) + > settings.shell_max_timeout_seconds.unwrap_or(120) + { + return Err(format!( + "profile {name:?}: shell default timeout exceeds shell maximum timeout" + )); + } + if let (Some(long), Some(short)) = ( + settings.image_resize.max_long_edge, + settings.image_resize.max_short_edge, + ) { + if short > long { + return Err(format!( + "profile {name:?}: image short edge exceeds long edge" + )); + } + } + Ok(()) +} diff --git a/src/profile/openai_responses.rs b/src/profile/openai_responses.rs index 6c4b7ba5..4e7aa197 100644 --- a/src/profile/openai_responses.rs +++ b/src/profile/openai_responses.rs @@ -52,11 +52,6 @@ pub(super) fn parse( )); } reject_inert_dsflash_settings(&ephemeral, &model_params, name)?; - if ephemeral.timeout_ms.is_some() { - return Err(format!( - "profile {name:?}: timeout settings are unsupported for OpenAI Responses" - )); - } ephemeral.max_output_tokens = max_output_tokens.or(ephemeral.max_output_tokens); let settings = parse_settings(enabled, effort, summary, verbosity, prompt_caching, name)?; diff --git a/src/profile/provider_settings.rs b/src/profile/provider_settings.rs index 30a97c4c..c9dcb482 100644 --- a/src/profile/provider_settings.rs +++ b/src/profile/provider_settings.rs @@ -66,7 +66,7 @@ pub struct OpenAiResponsesSettings { /// The Codex Responses provider-scoped settings carried by a profile. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CodexResponsesSettings { - pub reasoning_enabled: bool, + pub reasoning_effort: Option, } /// The provider-scoped parse outcome for one profile. diff --git a/src/profile/tests.rs b/src/profile/tests.rs index bd7baa14..cf93f466 100644 --- a/src/profile/tests.rs +++ b/src/profile/tests.rs @@ -1,5 +1,7 @@ +mod host_settings; use super::*; +mod codex_context_limit; mod codex_task_timeouts; mod codex_tools; mod dsflash_effort; diff --git a/src/profile/tests/codex_context_limit.rs b/src/profile/tests/codex_context_limit.rs new file mode 100644 index 00000000..9f381c22 --- /dev/null +++ b/src/profile/tests/codex_context_limit.rs @@ -0,0 +1,47 @@ +use super::*; + +fn profile() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../tests/fixtures/task-profile/astra-headless.json" + )) + .unwrap() +} + +#[test] +fn codex_preserves_configured_positive_context_limits() { + for limit in [1_u64, 262_143, 262_144, 300_000, 1_000_000] { + let mut value = profile(); + value["ephemeralSettings"]["context-limit"] = json!(limit); + let parsed = parse_profile_value(&value, "astra").unwrap(); + assert_eq!(parsed.ephemeral.context_limit, Some(limit)); + } +} + +#[test] +fn codex_rejects_invalid_context_limits() { + for limit in [ + json!(0), + json!(-1), + json!(1.5), + json!("300000"), + json!(null), + json!(true), + ] { + let mut value = profile(); + value["ephemeralSettings"]["context-limit"] = limit; + assert!(parse_profile_value(&value, "astra").is_err()); + } +} + +#[test] +fn codex_still_requires_an_explicit_context_limit() { + let mut value = profile(); + value["ephemeralSettings"] + .as_object_mut() + .unwrap() + .remove("context-limit"); + assert_eq!( + parse_profile_value(&value, "astra").unwrap_err(), + "profile \"astra\": missing required setting 'context-limit'" + ); +} diff --git a/src/profile/tests/codex_task_timeouts.rs b/src/profile/tests/codex_task_timeouts.rs index 26359e8d..a718667d 100644 --- a/src/profile/tests/codex_task_timeouts.rs +++ b/src/profile/tests/codex_task_timeouts.rs @@ -78,10 +78,10 @@ fn codex_host_task_timeouts_require_json_numbers_but_not_rust_ranges() { #[test] fn codex_owned_settings_remain_strict() { let mut context = astra_shape(); - context["ephemeralSettings"]["context-limit"] = json!(262_143); + context["ephemeralSettings"]["context-limit"] = json!(0); assert_eq!( parse_profile_value(&context, "astra-headless").unwrap_err(), - "profile \"astra-headless\": Codex 'context-limit' must be 262144" + "profile \"astra-headless\": 'context-limit' must be a positive integer" ); let mut stream_idle = astra_shape(); diff --git a/src/profile/tests/host_settings.rs b/src/profile/tests/host_settings.rs new file mode 100644 index 00000000..6d270dc1 --- /dev/null +++ b/src/profile/tests/host_settings.rs @@ -0,0 +1,163 @@ +use super::*; + +fn profiles() -> Vec { + vec![ + serde_json::from_str(include_str!( + "../../../tests/fixtures/task-profile/astra-headless.json" + )) + .unwrap(), + json!({"provider":"openai", "model":"test", "ephemeralSettings":{}}), + json!({"provider":"openai", "model":"test", "ephemeralSettings":{"apiMode":"responses"}}), + json!({"provider":"anthropic", "model":"test", "ephemeralSettings":{}}), + ] +} + +#[test] +fn host_keys_are_typed_for_every_provider_and_survive_resolution() { + for mut value in profiles() { + let map = value["ephemeralSettings"].as_object_mut().unwrap(); + for (key, n) in [ + ("shell-default-timeout-seconds", 180), + ("shell-max-timeout-seconds", 540), + ("stream-first-response-timeout-ms", 1234), + ("image-resize.maxLongEdge", 2048), + ("image-resize.maxShortEdge", 768), + ("image-resize.maxPixels", 1572864), + ] { + map.insert(key.into(), json!(n)); + } + let profile = parse_profile_value(&value, "astramedium").unwrap(); + let e = &profile.ephemeral; + assert_eq!(e.shell_default_timeout_seconds, Some(180)); + assert_eq!(e.shell_max_timeout_seconds, Some(540)); + assert_eq!(e.timeout_ms, Some(1234)); + assert_eq!(e.image_resize.max_long_edge, Some(2048)); + assert_eq!(e.image_resize.max_short_edge, Some(768)); + assert_eq!(e.image_resize.max_pixels, Some(1572864)); + assert!(e.unsupported.is_empty()); + } +} + +#[test] +fn every_host_key_rejects_malformed_values_for_every_provider() { + for profile in profiles() { + for key in crate::profile::host::KEYS { + for bad in [ + json!(null), + json!(false), + json!("120"), + json!(0), + json!(-1), + json!(-2), + json!(1.5), + json!([]), + json!({}), + ] { + if [ + "shell-max-timeout-seconds", + "stream-first-response-timeout-ms", + ] + .contains(key) + && bad == json!(-1) + { + continue; + } + if *key == "stream-first-response-timeout-ms" && bad == json!(0) { + continue; + } + let mut value = profile.clone(); + value["ephemeralSettings"][key] = bad; + assert!(parse_profile_value(&value, "test").is_err(), "{key}"); + } + } + } +} + +#[test] +fn host_limits_reject_overflow_and_conflicts() { + for profile in profiles() { + for key in [ + "shell-default-timeout-seconds", + "shell-max-timeout-seconds", + "image-resize.maxLongEdge", + "image-resize.maxShortEdge", + "stream-first-response-timeout-ms", + ] { + let mut value = profile.clone(); + value["ephemeralSettings"][key] = json!(u64::MAX); + assert!(parse_profile_value(&value, "test").is_err(), "{key}"); + } + for (a, b) in [ + ("shell-default-timeout-seconds", "shell-max-timeout-seconds"), + ("image-resize.maxShortEdge", "image-resize.maxLongEdge"), + ] { + let mut value = profile.clone(); + value["ephemeralSettings"][a] = json!(200); + value["ephemeralSettings"][b] = json!(100); + assert!(parse_profile_value(&value, "test").is_err()); + } + } +} + +#[test] +fn full_native_named_astramedium_retains_model_context_and_effort() { + // Synthetic, credential-free named profile; never reads a user's profile. + let mut value = profiles().remove(0); + value["model"] = json!("gpt-6-astra"); + let map = value["ephemeralSettings"].as_object_mut().unwrap(); + map.insert("context-limit".into(), json!(300000)); + map.insert("reasoning.effort".into(), json!("medium")); + for (key, n) in [ + ("shell-default-timeout-seconds", 120), + ("shell-max-timeout-seconds", 600), + ("stream-first-response-timeout-ms", 300000), + ("image-resize.maxLongEdge", 2048), + ("image-resize.maxShortEdge", 768), + ("image-resize.maxPixels", 1572864), + ] { + map.insert(key.into(), json!(n)); + } + let profile = parse_profile_value(&value, "astramedium").unwrap(); + assert_eq!(profile.model, "gpt-6-astra"); + assert_eq!(profile.ephemeral.context_limit, Some(300000)); + assert!(format!("{:?}", profile).contains("medium")); + for key in [ + "image-resize.maxLongEdges", + "shell-timeout-seconds", + "stream-first-response-timeout", + ] { + let mut bad = value.clone(); + bad["ephemeralSettings"][key] = json!(120); + assert!(parse_profile_value(&bad, "astramedium").is_err()); + } +} + +#[test] +fn zero_response_timeout_selects_native_default() { + for mut value in profiles() { + value["ephemeralSettings"]["stream-first-response-timeout-ms"] = json!(0); + assert_eq!( + parse_profile_value(&value, "test") + .unwrap() + .ephemeral + .timeout_ms, + None + ); + } +} + +#[test] +fn negative_timeout_sentinels_remove_profile_caps_but_not_host_safety_budgets() { + for mut value in profiles() { + value["ephemeralSettings"]["shell-default-timeout-seconds"] = json!(2700); + value["ephemeralSettings"]["shell-max-timeout-seconds"] = json!(-1); + value["ephemeralSettings"]["stream-first-response-timeout-ms"] = json!(-1); + let profile = parse_profile_value(&value, "astramedium").unwrap(); + assert_eq!(profile.ephemeral.shell_default_timeout_seconds, Some(2700)); + assert_eq!( + profile.ephemeral.shell_max_timeout_seconds, + Some(u32::MAX as u64) + ); + assert_eq!(profile.ephemeral.timeout_ms, None); + } +} diff --git a/src/redact/tests/issue243_boundary.rs b/src/redact/tests/issue243_boundary.rs index 031bcd0c..c8171381 100644 --- a/src/redact/tests/issue243_boundary.rs +++ b/src/redact/tests/issue243_boundary.rs @@ -148,6 +148,7 @@ fn registered_tool_cut_inside_the_digest_omits_it_whole_every_time() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: crate::tools::ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: std::time::Duration::from_secs(60), max_shell_timeout: std::time::Duration::from_secs(60), allow_shell: false, }, diff --git a/src/tools.rs b/src/tools.rs index 41aa0c04..6d2f6569 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -368,6 +368,7 @@ pub struct ShellConfig { pub max_shell_output: usize, /// Ceiling for any single shell command, independent of what the model asks for. pub max_shell_timeout: std::time::Duration, + pub default_shell_timeout: std::time::Duration, /// Whether `run_shell_command` is registered (`--allow-shell` gate). pub allow_shell: bool, } @@ -862,6 +863,7 @@ fn list_directory_tool( fn shell_tool( fd: i32, args: &BTreeMap, + default_timeout: std::time::Duration, max_timeout: std::time::Duration, max_output: usize, ) -> Result { @@ -870,14 +872,13 @@ fn shell_tool( if command.trim().is_empty() { return Err("command must not be empty".into()); } - let timeout = bounded( - arg_u64(args, "timeout_seconds")?, - max_timeout.as_secs() as usize, - max_timeout.as_secs() as usize, - ); - let timeout = std::time::Duration::from_secs(u64::from( - u32::try_from(timeout.max(1)).unwrap_or(u32::MAX), - )); + // Default/cap separation adapted from origin/b3-issue-190 src/tools/shell.rs. + // Keep this runtime bounded: -1 is not an unlimited escape hatch. + let seconds = arg_u64(args, "timeout_seconds")?.unwrap_or(default_timeout.as_secs()); + if seconds == 0 { + return Err("timeout_seconds must be a positive integer".into()); + } + let timeout = std::time::Duration::from_secs(seconds.min(max_timeout.as_secs())); let o = crate::process::run_cmd(crate::process::CmdSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), command.to_string()], @@ -998,6 +999,7 @@ pub(crate) fn execute_tool_with_limit( shell_tool( crate::tools::shell_cwd_fd(&config.ws), &map, + config.shell.default_shell_timeout, config.shell.max_shell_timeout, config.shell.max_shell_output.min(output_limit), ) diff --git a/src/tools/tests.rs b/src/tools/tests.rs index 06aceabd..eea9ecbd 100644 --- a/src/tools/tests.rs +++ b/src/tools/tests.rs @@ -19,6 +19,7 @@ fn cfg(root: &std::path::Path) -> ToolConfig { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(60), max_shell_timeout: Duration::from_secs(60), allow_shell: false, }, @@ -114,6 +115,7 @@ fn shell_nonzero_and_signal_return_ok_false() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(30), max_shell_timeout: Duration::from_secs(30), allow_shell: true, }, @@ -334,6 +336,7 @@ fn shell_output_total_is_bounded_for_success_and_error() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 4096, + default_shell_timeout: Duration::from_secs(30), max_shell_timeout: Duration::from_secs(30), allow_shell: true, }, @@ -866,3 +869,24 @@ fn deterministic_swap_write_then_read_is_consistent() { assert!(!residue); } } + +#[test] +fn issue286_shell_default_and_maximum_have_distinct_runtime_effects() { + let d = tempfile::tempdir().unwrap(); + let mut config = cfg(d.path()); + config.shell.allow_shell = true; + config.shell.default_shell_timeout = Duration::from_secs(1); + config.shell.max_shell_timeout = Duration::from_secs(3); + let execute = |args| execute_tool(d.path(), "run_shell_command", args, &config); + // Omitted timeout uses the default, not the maximum. + assert!(!execute(json!({"command":"sleep 2"})).0); + // Explicit timeout may exceed the default, up to the cap. + assert!(execute(json!({"command":"sleep 2", "timeout_seconds":3})).0); + // A model cannot escape the maximum by asking for a larger value. + let started = std::time::Instant::now(); + assert!(!execute(json!({"command":"sleep 10", "timeout_seconds":30})).0); + assert!(started.elapsed() < Duration::from_secs(8)); + for bad in [json!(0), json!(-1), json!(1.5), json!("2"), json!(null)] { + assert!(!execute(json!({"command":"true", "timeout_seconds":bad})).0); + } +} diff --git a/src/tools/tests/advanced.rs b/src/tools/tests/advanced.rs index 195158dd..ade311f2 100644 --- a/src/tools/tests/advanced.rs +++ b/src/tools/tests/advanced.rs @@ -276,6 +276,7 @@ fn file_tools_execute_through_retained_capability() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(60), max_shell_timeout: Duration::from_secs(60), allow_shell: false, }, @@ -316,6 +317,7 @@ fn renamed_workspace_keeps_file_and_shell_tools_on_retained_directory() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(60), max_shell_timeout: Duration::from_secs(60), allow_shell: true, }, diff --git a/src/tools/tests/hash_gate_caps.rs b/src/tools/tests/hash_gate_caps.rs index a8578917..c9b2546b 100644 --- a/src/tools/tests/hash_gate_caps.rs +++ b/src/tools/tests/hash_gate_caps.rs @@ -22,6 +22,7 @@ fn replace_with_budget( digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: crate::tools::ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: std::time::Duration::from_secs(60), max_shell_timeout: std::time::Duration::from_secs(60), allow_shell: false, }, diff --git a/src/tools/tests/read_recovery.rs b/src/tools/tests/read_recovery.rs index 20f4407a..596b968b 100644 --- a/src/tools/tests/read_recovery.rs +++ b/src/tools/tests/read_recovery.rs @@ -16,6 +16,7 @@ fn cfg_floor(root: &std::path::Path, floor: usize) -> ToolConfig { digest_size_floor: floor, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(60), max_shell_timeout: Duration::from_secs(60), allow_shell: false, }, @@ -338,6 +339,7 @@ fn read_file_total_is_bounded_including_frame() { digest_size_floor: crate::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(30), max_shell_timeout: Duration::from_secs(30), allow_shell: false, }, diff --git a/tests/fixtures/profiles/astramedium-native.json b/tests/fixtures/profiles/astramedium-native.json new file mode 100644 index 00000000..99a46297 --- /dev/null +++ b/tests/fixtures/profiles/astramedium-native.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "provider": "codex", + "model": "gpt-6-astra", + "modelParams": { + "maxOutputTokens": 40000 + }, + "ephemeralSettings": { + "base-url": "https://chatgpt.com/backend-api/codex", + "reasoning.enabled": true, + "reasoning.effort": "medium", + "reasoning.adaptiveThinking": true, + "reasoning.includeInResponse": true, + "reasoning.includeInContext": true, + "reasoning.stripFromContext": "none", + "reasoning.summary": "auto", + "text.verbosity": "medium", + "prompt-caching": "24h", + "context-limit": 300000, + "maxTurnsPerPrompt": -1, + "loopDetectionEnabled": false, + "emojifilter": "auto", + "tools.disabled": [ + "google_web_fetch", + "google_web_search" + ], + "stream-idle-timeout-ms": 0, + "task-default-timeout-seconds": 1800, + "task-max-timeout-seconds": 5400, + "shell-default-timeout-seconds": 2700, + "shell-max-timeout-seconds": -1, + "stream-first-response-timeout-ms": -1, + "image-resize.maxLongEdge": 2048, + "image-resize.maxShortEdge": 2048, + "image-resize.maxPixels": 1572864 + } +} diff --git a/tests/phase2.rs b/tests/phase2.rs index e6945e99..45352c9e 100644 --- a/tests/phase2.rs +++ b/tests/phase2.rs @@ -612,6 +612,7 @@ fn normalized_empty_object_cannot_execute() { digest_size_floor: llxprt_code_rs::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: llxprt_code_rs::tools::ShellConfig { max_shell_output: 4096, + default_shell_timeout: std::time::Duration::from_secs(5), max_shell_timeout: std::time::Duration::from_secs(5), allow_shell: false, }, diff --git a/tests/process.rs b/tests/process.rs index fe9d40b8..e628f907 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -267,6 +267,7 @@ fn crate_tools_shell(cmd: &str) -> (bool, String) { digest_size_floor: llxprt_code_rs::context_ingress::filter::DEFAULT_DIGEST_SIZE_FLOOR, shell: llxprt_code_rs::tools::ShellConfig { max_shell_output: 64 * 1024, + default_shell_timeout: Duration::from_secs(3), max_shell_timeout: Duration::from_secs(3), allow_shell: true, }, diff --git a/tests/prompt_cache.rs b/tests/prompt_cache.rs new file mode 100644 index 00000000..cabbf1fb --- /dev/null +++ b/tests/prompt_cache.rs @@ -0,0 +1,325 @@ +//! Exact HTTP-byte prefix invariants through the compiled process-per-turn CLI. +use serde_json::{json, Value}; +use std::io::{BufRead, Read, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::time::{Duration, Instant}; + +fn field<'a>(body: &'a str, key: &str) -> &'a str { + let start = body.find(&format!("\"{key}\":")).unwrap() + key.len() + 3; + let rest = &body[start..]; + let mut stream = serde_json::Deserializer::from_str(rest).into_iter::(); + stream.next().unwrap().unwrap(); + &rest[..stream.byte_offset()] +} + +fn response(provider: &str, round: usize) -> Value { + let tool = round % 2 == 0; + match provider { + "anthropic" => { + json!({"id":"msg", "type":"message", "role":"assistant", "model":"fixture", "stop_reason": if tool {"tool_use"} else {"end_turn"}, + "content": if tool {json!([{"type":"tool_use","id":"call","name":"read_file","input":{"path":"fixture.txt"}}])} else {json!([{"type":"text","text":"done"}])}, + "usage":{"input_tokens":20,"output_tokens":5,"cache_creation_input_tokens":30,"cache_read_input_tokens":50}}) + } + "openai-responses" => { + json!({"id":"resp", "object":"response", "created_at":1, "model":"fixture", "status":"completed", + "output": if tool {json!([{"id":"item","type":"function_call","status":"completed","call_id":"call","name":"read_file","arguments":"{\"path\":\"fixture.txt\"}"}])} else {json!([{"id":"item2","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"done"}]}])}, + "usage":{"input_tokens":100,"output_tokens":5,"total_tokens":105,"input_tokens_details":{"cached_tokens":50}}}) + } + _ => { + json!({"id":"chat", "object":"chat.completion", "created":1, "model":"fixture", "choices":[{"index":0,"finish_reason":if tool {"tool_calls"} else {"stop"}, "message": if tool {json!({"role":"assistant","content":null,"tool_calls":[{"id":"call","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"fixture.txt\"}"}}]})} else {json!({"role":"assistant","content":"done"})}}], + "usage":{"prompt_tokens":100,"completion_tokens":5,"total_tokens":105,"prompt_tokens_details":{"cached_tokens":50}}}) + } + } +} + +#[test] +fn exact_wire_round_and_turn_prefixes_and_cache_accounting() { + for provider in ["openai", "openai-responses", "anthropic"] { + for off in [false, true] { + run_wire_case(provider, off, false); + run_wire_case(provider, off, true); + } + } +} + +fn run_wire_case(provider: &'static str, off: bool, partial_usage: bool) { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + std::fs::create_dir_all(root.join("tmp")).unwrap(); + let work = tempfile::tempdir_in(root.join("tmp")).unwrap(); + std::fs::create_dir(work.path().join("profiles")).unwrap(); + std::fs::write(work.path().join("fixture.txt"), "stable evidence").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for round in 0..4 { + let deadline = Instant::now() + Duration::from_secs(30); + let mut socket = loop { + match listener.accept() { + Ok((socket, _)) => break socket, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "missing request {round}"); + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => panic!("{e}"), + } + }; + socket.set_nonblocking(false).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(20))) + .unwrap(); + let mut reader = std::io::BufReader::new(&mut socket); + let mut length = None; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + if let Some(n) = line.to_lowercase().strip_prefix("content-length:") { + length = Some(n.trim().parse::().unwrap()); + } + } + let mut body = vec![0; length.unwrap()]; + reader.read_exact(&mut body).unwrap(); + bodies.push(String::from_utf8(body).unwrap()); + let mut value = response(provider, round); + if partial_usage { + match round { + 0 => { + value.as_object_mut().unwrap().remove("usage"); + } + 1 => { + value["usage"] = if provider == "anthropic" { + json!({"input_tokens": 0, "output_tokens": 0, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}) + } else if provider == "openai" { + json!({"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, + "prompt_tokens_details": {"cached_tokens": 0}}) + } else { + json!({"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, + "input_tokens_details": {"cached_tokens": 0}}) + }; + } + 2 => { + let usage = value["usage"].as_object_mut().unwrap(); + usage.remove("cache_creation_input_tokens"); + usage.remove("prompt_tokens_details"); + usage.remove("input_tokens_details"); + } + _ => {} + } + } + let reply = value + .to_string() + .replace("\"call\"", &format!("\"call-{}\"", round / 2)); + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).unwrap(); + } + bodies + }); + let endpoint = if provider == "openai-responses" { + format!("http://{addr}/v1/responses") + } else { + format!("http://{addr}/v1") + }; + let mut ephemeral = json!({"base-url":endpoint,"auth-key":"fixture-key"}); + if off { + ephemeral["prompt-caching"] = json!("off"); + } + std::fs::write( + work.path().join("profiles/cache.json"), + json!({"provider":provider,"model":"fixture","ephemeralSettings":ephemeral, + "modelParams": if provider == "openai" { json!({"prompt_cache_key":"unowned-override"}) } else { json!({}) }}) + .to_string(), + ) + .unwrap(); + for turn in 0..2 { + let output = Command::new(env!("CARGO_BIN_EXE_llxprt-code-rs")) + .env("LLXPRT_CONFIG_HOME", work.path()) + .args(["--profile", "cache", "--session", "cache-session", "--cwd"]) + .arg(work.path()) + .args([ + "-p", + &format!("Read fixture.txt then say done, turn {turn}"), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{provider} off={off} turn={turn}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + let aggregate: Value = stderr + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["event"] == "prompt_cache_run") + .next_back() + .unwrap(); + assert_eq!(aggregate["usage"]["calls"], 2); + assert_eq!( + aggregate["usage"]["hit_ratio"], + if partial_usage && turn == 0 { + Value::Null + } else { + json!(0.5) + } + ); + assert_eq!( + aggregate["usage"]["measured_calls"], + if partial_usage { 1 } else { 2 } + ); + let events: Vec = stderr + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["event"] == "prompt_cache_call") + .collect(); + assert_eq!(events.len(), 2); + if partial_usage && turn == 0 { + for key in [ + "reported_input_tokens", + "cached_input_tokens", + "uncached_input_tokens", + "cache_creation_input_tokens", + "total_input_tokens", + ] { + assert!( + events[0][key].is_null(), + "{provider}: missing {key} fabricated: {}", + events[0] + ); + } + assert_eq!(events[1]["total_input_tokens"], 0); + assert_eq!(events[1]["cached_input_tokens"], 0); + assert_eq!(events[1]["uncached_input_tokens"], 0); + } else { + for event in &events[if partial_usage { 1 } else { 0 }..] { + assert_eq!(event["total_input_tokens"], 100); + assert_eq!(event["cached_input_tokens"], 50); + assert_eq!(event["uncached_input_tokens"], 50); + } + if partial_usage { + assert!(events[0]["uncached_input_tokens"].is_null()); + assert!(events[0]["cache_creation_input_tokens"].is_null()); + } + } + } + let bodies = server.join().unwrap(); + let tools = field(&bodies[0], "tools"); + for body in &bodies[1..] { + assert_eq!(field(body, "tools").as_bytes(), tools.as_bytes()); + } + let parsed_tools: Value = serde_json::from_str(tools).unwrap(); + let names: Vec<&str> = parsed_tools + .as_array() + .unwrap() + .iter() + .map(|tool| { + tool["name"] + .as_str() + .or_else(|| tool["function"]["name"].as_str()) + .unwrap() + }) + .collect(); + assert_eq!( + names, + [ + "read_file", + "write_file", + "replace", + "list_directory", + "search_file_content" + ] + ); + if provider == "anthropic" { + let system = field(&bodies[0], "system"); + for body in &bodies { + assert_eq!(field(body, "system"), system); + } + assert_eq!(system.contains("cache_control"), !off); + assert_eq!(tools.contains("cache_control"), !off); + let system_blocks: Value = serde_json::from_str(system).unwrap(); + if !off { + assert_eq!(system_blocks.as_array().unwrap().len(), 1); + assert_eq!( + system_blocks[0]["cache_control"], + json!({"type":"ephemeral"}) + ); + } + for (index, tool) in parsed_tools.as_array().unwrap().iter().enumerate() { + if !off && index == names.len() - 1 { + assert_eq!(tool["cache_control"], json!({"type":"ephemeral"})); + } else { + assert!(tool.get("cache_control").is_none()); + } + } + for body in &bodies { + assert!( + !field(body, "messages").contains("cache_control"), + "mutable transcript marked" + ); + assert_eq!( + body.matches("\"cache_control\"").count(), + if off { 0 } else { 2 } + ); + } + let changed = system.replacen("You", "Timestamp: 1. You", 1); + assert_ne!(system.as_bytes(), changed.as_bytes(), "negative control"); + } else { + for body in &bodies { + let value: Value = serde_json::from_str(body).unwrap(); + if off { + assert!(value.get("prompt_cache_key").is_none()); + } else { + assert_eq!(value["prompt_cache_key"], "cache-session"); + } + } + } + let messages = if provider == "openai-responses" { + "input" + } else { + "messages" + }; + for pair in [&bodies[0..2], &bodies[2..4]] { + let first = field(&pair[0], messages); + let next = field(&pair[1], messages); + assert!( + next.as_bytes() + .starts_with(&first.as_bytes()[..first.len() - 1]), + "round prefix drift: {provider}" + ); + } + // History representation can change across turns; the system head must not. + if provider == "openai-responses" { + let instructions = field(&bodies[0], "instructions"); + for body in &bodies { + assert_eq!(field(body, "instructions"), instructions); + } + assert_ne!( + instructions, + instructions.replacen("You", "Timestamp: 1. You", 1) + ); + } + if provider == "openai" { + let first = &field(&bodies[0], messages)[1..]; + let mut stream = serde_json::Deserializer::from_str(first).into_iter::(); + stream.next().unwrap().unwrap(); + let system = &first[..stream.byte_offset()]; + for body in &bodies { + let raw = field(body, messages); + assert!(raw[1..].starts_with(system), "turn head drift: {provider}"); + } + assert!(!field(&bodies[0], messages)[1..] + .starts_with(&system.replace("You", "Timestamp: 1. You"))); + } + let mut reordered = parsed_tools.as_array().unwrap().clone(); + reordered.swap(0, 1); + assert_ne!( + tools.as_bytes(), + serde_json::to_vec(&reordered).unwrap(), + "tool-order negative control" + ); +} diff --git a/vendor/serdes-ai-models/src/anthropic/model.rs b/vendor/serdes-ai-models/src/anthropic/model.rs index b370ae99..8228ad5b 100644 --- a/vendor/serdes-ai-models/src/anthropic/model.rs +++ b/vendor/serdes-ai-models/src/anthropic/model.rs @@ -541,9 +541,13 @@ impl AnthropicModel { }); let usage = RequestUsage { - request_tokens: Some(resp.usage.input_tokens), - response_tokens: Some(resp.usage.output_tokens), - total_tokens: Some(resp.usage.input_tokens + resp.usage.output_tokens), + request_tokens: resp.usage.input_tokens, + response_tokens: resp.usage.output_tokens, + total_tokens: resp + .usage + .input_tokens + .zip(resp.usage.output_tokens) + .and_then(|(input, output)| input.checked_add(output)), cache_creation_tokens: resp.usage.cache_creation_input_tokens, cache_read_tokens: resp.usage.cache_read_input_tokens, details: None, @@ -834,8 +838,8 @@ mod tests { stop_reason: Some("end_turn".to_string()), stop_sequence: None, usage: AnthropicUsage { - input_tokens: 10, - output_tokens: 5, + input_tokens: Some(10), + output_tokens: Some(5), cache_creation_input_tokens: None, cache_read_input_tokens: None, }, diff --git a/vendor/serdes-ai-models/src/anthropic/stream.rs b/vendor/serdes-ai-models/src/anthropic/stream.rs index 0ced5fed..910b24a8 100644 --- a/vendor/serdes-ai-models/src/anthropic/stream.rs +++ b/vendor/serdes-ai-models/src/anthropic/stream.rs @@ -29,7 +29,7 @@ pin_project! { message_id: Option, model: Option, // Usage tracking - input_tokens: u64, + input_tokens: Option, output_tokens: u64, cache_creation_tokens: Option, cache_read_tokens: Option, @@ -76,7 +76,7 @@ where blocks: HashMap::new(), message_id: None, model: None, - input_tokens: 0, + input_tokens: None, output_tokens: 0, cache_creation_tokens: None, cache_read_tokens: None, @@ -198,7 +198,7 @@ fn process_event( blocks: &mut HashMap, message_id: &mut Option, model: &mut Option, - input_tokens: &mut u64, + input_tokens: &mut Option, output_tokens: &mut u64, cache_creation_tokens: &mut Option, cache_read_tokens: &mut Option, diff --git a/vendor/serdes-ai-models/src/anthropic/types.rs b/vendor/serdes-ai-models/src/anthropic/types.rs index f6534bde..5eb2d6dd 100644 --- a/vendor/serdes-ai-models/src/anthropic/types.rs +++ b/vendor/serdes-ai-models/src/anthropic/types.rs @@ -556,6 +556,7 @@ pub struct MessagesResponse { /// Stop sequence if hit. pub stop_sequence: Option, /// Token usage. + #[serde(default)] pub usage: AnthropicUsage, } @@ -597,10 +598,10 @@ pub enum ResponseContentBlock { pub struct AnthropicUsage { /// Input tokens. #[serde(default)] - pub input_tokens: u64, + pub input_tokens: Option, /// Output tokens. #[serde(default)] - pub output_tokens: u64, + pub output_tokens: Option, /// Tokens used to create cache. #[serde(default)] pub cache_creation_input_tokens: Option, @@ -881,7 +882,7 @@ mod tests { let resp: MessagesResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.id, "msg_123"); assert_eq!(resp.content.len(), 1); - assert_eq!(resp.usage.input_tokens, 10); + assert_eq!(resp.usage.input_tokens, Some(10)); } #[test] diff --git a/vendor/serdes-ai-responses/src/client/assembler.rs b/vendor/serdes-ai-responses/src/client/assembler.rs index 78de8d42..b5dcd7e3 100644 --- a/vendor/serdes-ai-responses/src/client/assembler.rs +++ b/vendor/serdes-ai-responses/src/client/assembler.rs @@ -137,7 +137,11 @@ pub(super) fn stream_complete( input_tokens, output_tokens, cache_creation_tokens: None, - cache_read_tokens: None, + cache_read_tokens: response + .usage + .as_ref() + .and_then(|usage| usage.input_tokens_details.as_ref()) + .and_then(|details| details.cached_tokens), } } @@ -210,6 +214,9 @@ mod tests { id: "resp_1".into(), usage: Some(ResponseUsage { input_tokens: Some(7), + input_tokens_details: Some(crate::types::InputTokensDetails { + cached_tokens: Some(5), + }), output_tokens: Some(4), total_tokens: Some(11), }), @@ -224,6 +231,7 @@ mod tests { assert_eq!(complete.finish_reason, FinishReason::Stop); assert_eq!(complete.input_tokens, Some(7)); assert_eq!(complete.output_tokens, Some(4)); + assert_eq!(complete.cache_read_tokens, Some(5)); } other => panic!("expected stream complete, got {other:?}"), } @@ -237,6 +245,7 @@ mod tests { "gpt-4o", &CreateResponseRequest { model: "gpt-4o".into(), + prompt_cache_key: None, input: crate::types::ResponseInput::Text(String::new()), instructions: None, tools: None, diff --git a/vendor/serdes-ai-responses/src/client/mod.rs b/vendor/serdes-ai-responses/src/client/mod.rs index 86e53d50..6b07d027 100644 --- a/vendor/serdes-ai-responses/src/client/mod.rs +++ b/vendor/serdes-ai-responses/src/client/mod.rs @@ -90,6 +90,7 @@ struct Inner { codex_http: bool, headers: Vec<(String, String)>, reasoning: Option, + prompt_cache_key: Option, http: reqwest::Client, profile: ModelProfile, session: Mutex, @@ -171,6 +172,7 @@ impl OpenResponsesModel { codex_http: false, headers: Vec::new(), reasoning: None, + prompt_cache_key: None, http: reqwest::Client::new(), profile: openai_gpt4o_profile(), session: Mutex::new(Session { @@ -228,6 +230,15 @@ impl OpenResponsesModel { self } + /// Set session prompt-cache routing independently of response continuation. + #[must_use] + pub fn with_prompt_cache_key(mut self, key: Option) -> Self { + Arc::get_mut(&mut self.inner) + .expect("model already in use; configure before sharing") + .prompt_cache_key = key; + self + } + /// Configure reasoning (effort/summary) for every turn. #[must_use] pub fn with_reasoning(mut self, reasoning: ReasoningSettings) -> Self { @@ -510,6 +521,7 @@ fn build_request( }; Ok(CreateResponseRequest { model: inner.model_name.clone(), + prompt_cache_key: inner.prompt_cache_key.clone(), input: if items.is_empty() && !inner.codex_http { crate::types::ResponseInput::Text(String::new()) } else { @@ -805,7 +817,9 @@ impl OpenResponsesModel { response_tokens: usage.output_tokens, total_tokens: usage.total_tokens, cache_creation_tokens: None, - cache_read_tokens: None, + cache_read_tokens: usage + .input_tokens_details + .and_then(|details| details.cached_tokens), details: None, }), vendor_id: Some(object.id), diff --git a/vendor/serdes-ai-responses/src/types.rs b/vendor/serdes-ai-responses/src/types.rs index 806f4481..953a3567 100644 --- a/vendor/serdes-ai-responses/src/types.rs +++ b/vendor/serdes-ai-responses/src/types.rs @@ -24,6 +24,10 @@ pub struct CreateResponseRequest { /// routing. pub model: String, + /// Session identity for prompt-cache routing on stateless HTTP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + /// Conversation input: a plain string or a list of input items. #[serde(default)] pub input: ResponseInput, @@ -650,9 +654,20 @@ pub struct IncompleteDetails { pub reason: String, } +/// Input token detail accounting reported by Responses providers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InputTokensDetails { + /// Cache reads, absent rather than zero if omitted by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, +} + /// Token usage reported on a response. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ResponseUsage { + /// Provider-reported input cache details, absent when not reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens_details: Option, /// Input (prompt) tokens. #[serde(default, skip_serializing_if = "Option::is_none")] pub input_tokens: Option, @@ -1133,6 +1148,7 @@ mod tests { "gpt-4o", &CreateResponseRequest { model: "gpt-4o".into(), + prompt_cache_key: None, input: ResponseInput::Text("".into()), instructions: None, tools: None, diff --git a/vendor/serdes-ai-responses/tests/client_ws.rs b/vendor/serdes-ai-responses/tests/client_ws.rs index 0865eb46..8ddd13ae 100644 --- a/vendor/serdes-ai-responses/tests/client_ws.rs +++ b/vendor/serdes-ai-responses/tests/client_ws.rs @@ -85,6 +85,7 @@ async fn send_completed_turn(ws: &mut FakeWs, id: &str, request: &CreateResponse }]; response.usage = Some(ResponseUsage { input_tokens: Some(1), + input_tokens_details: None, output_tokens: Some(1), total_tokens: Some(2), });