From e0c06e7452c081af903626d251f8d0721520356a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Jul 2026 18:02:08 -0600 Subject: [PATCH 01/12] Add explicit OpenAI conversation compaction --- crates/anthropic/src/completion.rs | 6 +- crates/language_model/src/language_model.rs | 18 ++ crates/language_model_core/src/request.rs | 5 + .../language_models/src/provider/open_ai.rs | 95 ++++++++++- .../src/language_models_cloud.rs | 159 ++++++++++++++++-- crates/open_ai/src/completion.rs | 75 ++++++++- crates/open_ai/src/responses.rs | 139 ++++++++++++++- 7 files changed, 472 insertions(+), 25 deletions(-) diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 884ed1a1ddde9b..640a5a28273840 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -165,10 +165,12 @@ fn to_anthropic_content(content: MessageContent) -> Result Ok(None), } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 2e060049d0b056..2f448c5883ce37 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -131,6 +131,24 @@ pub trait LanguageModel: Send + Sync { false } + fn supports_explicit_compaction(&self) -> bool { + false + } + + fn compact( + &self, + _request: LanguageModelRequest, + _cx: &AsyncApp, + ) -> BoxFuture<'static, Result> { + let provider = self.provider_name(); + async move { + Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "{provider} does not support explicit compaction" + ))) + } + .boxed() + } + /// Whether this model supports images fn supports_images(&self) -> bool; diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index a661874b0eb329..46360bb3f18513 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -275,6 +275,11 @@ pub enum CompactionContent { id: Option>, encrypted_content: Arc, }, + /// A provider's canonical replacement window, which must be replayed + /// unchanged and supersedes all request messages that precede it. + ProviderWindow { + items: Arc<[serde_json::Value]>, + }, } impl MessageContent { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index eabb0ed5032958..44511f7000a104 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,16 +5,19 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, - ProviderSettingsView, RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, CompactionContent, EnvVar, + FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, + OPEN_AI_PROVIDER_NAME, ProviderSettingsView, RateLimiter, env_var, }; use open_ai::{ ResponseStreamEvent, - responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, + responses::{ + CompactRequest, CompactedResponse, Request as ResponseRequest, + StreamEvent as ResponsesStreamEvent, compact_response, stream_response, + }, stream_completion, }; use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; @@ -425,6 +428,40 @@ impl OpenAiLanguageModel { async move { Ok(future.await?.boxed()) }.boxed() } + + fn compact_response( + &self, + request: CompactRequest, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result> { + let http_client = self.http_client.clone(); + + let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + let extra_headers = OpenAiLanguageModelProvider::settings(cx) + .custom_headers + .clone(); + (state.api_key_state.key(&api_url), api_url, extra_headers) + }); + + let provider = PROVIDER_NAME; + let future = self.request_limiter.run(async move { + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { provider }); + }; + Ok(compact_response( + http_client.as_ref(), + provider.0.as_str(), + &api_url, + &api_key, + request, + &extra_headers, + ) + .await?) + }); + + future.boxed() + } } impl LanguageModel for OpenAiLanguageModel { @@ -499,6 +536,50 @@ impl LanguageModel for OpenAiLanguageModel { self.model.supports_compaction() } + fn supports_explicit_compaction(&self) -> bool { + self.model.supports_compaction() + } + + fn compact( + &self, + mut request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result> { + if !self.supports_explicit_compaction() { + return async { + Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "this OpenAI model does not support explicit compaction" + ))) + } + .boxed(); + } + + normalize_open_ai_response_thinking_effort(&mut request, &self.model); + let request = into_open_ai_response( + request, + self.model.id(), + self.model.supports_parallel_tool_calls(), + self.model.supports_prompt_cache_key(), + self.max_output_tokens(), + default_thinking_reasoning_effort(&self.model), + self.model + .supported_reasoning_efforts() + .contains(&open_ai::ReasoningEffort::None), + ); + let request = CompactRequest { + model: request.model, + input: request.input, + }; + let response = self.compact_response(request, cx); + async move { + let response = response.await?; + Ok(CompactionContent::ProviderWindow { + items: response.output.into(), + }) + } + .boxed() + } + fn supported_effort_levels(&self) -> Vec { supported_thinking_effort_levels(&self.model) } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 46060e92c7b21e..35b7b4cb5b8ae8 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -19,8 +19,8 @@ use http_client::{ AsyncBody, HttpClient, HttpClientWithUrl, HttpRequestExt, Method, Response, StatusCode, }; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, DisabledReason, GOOGLE_PROVIDER_ID, - GOOGLE_PROVIDER_NAME, LanguageModel, LanguageModelCompletionError, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactionContent, DisabledReason, + GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, OPEN_AI_PROVIDER_ID, @@ -124,9 +124,49 @@ impl CloudLanguageModel { auth_context: TP::AuthContext, app_version: Option, body: CompletionBody, + ) -> Result { + Self::perform_llm_request( + "/completions", + true, + http_client, + token_provider, + auth_context, + app_version, + body, + ) + .await + } + + async fn perform_llm_compaction( + http_client: &HttpClientWithUrl, + token_provider: &TP, + auth_context: TP::AuthContext, + app_version: Option, + body: CompletionBody, + ) -> Result { + Self::perform_llm_request( + "/completions/compact", + false, + http_client, + token_provider, + auth_context, + app_version, + body, + ) + .await + } + + async fn perform_llm_request( + path: &str, + request_status_messages: bool, + http_client: &HttpClientWithUrl, + token_provider: &TP, + auth_context: TP::AuthContext, + app_version: Option, + body: CompletionBody, ) -> Result { let url = http_client - .build_zed_llm_url("/completions", &[]) + .build_zed_llm_url(path, &[]) .map_err(LanguageModelCompletionError::Other)?; let body = serde_json::to_string(&body).map_err(|error| { LanguageModelCompletionError::SerializeRequest { @@ -136,17 +176,20 @@ impl CloudLanguageModel { })?; let mut response = authenticated_llm_request(http_client, token_provider, auth_context, |token| { - Ok(http_client::Request::builder() + let mut request = http_client::Request::builder() .method(Method::POST) .uri(url.as_ref()) .when_some(app_version.as_ref(), |builder, app_version| { builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string()) }) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {token}")) - .header(CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") - .header(CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, "true") - .body(body.clone().into())?) + .header("Authorization", format!("Bearer {token}")); + if request_status_messages { + request = request + .header(CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") + .header(CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, "true"); + } + Ok(request.body(body.clone().into())?) }) .await .map_err(|error| LanguageModelCompletionError::HttpSend { @@ -156,10 +199,11 @@ impl CloudLanguageModel { let status = response.status(); if status.is_success() { - let includes_status_messages = response - .headers() - .get(SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME) - .is_some(); + let includes_status_messages = request_status_messages + && response + .headers() + .get(SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME) + .is_some(); return Ok(PerformLlmCompletionResponse { response, @@ -376,6 +420,97 @@ impl LanguageModel for CloudLanguageModel bool { + self.model.provider == cloud_llm_client::LanguageModelProvider::OpenAi + && self.model.supports_server_side_compaction + } + + fn compact( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result> { + if !self.supports_explicit_compaction() { + return async { + Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "this cloud model does not support explicit compaction" + ))) + } + .boxed(); + } + + let thread_id = request.thread_id.clone(); + let prompt_id = request.prompt_id.clone(); + let app_version = self.app_version.clone(); + let provider_name = provider_name(&self.model.provider); + let supports_none_reasoning_effort = + self.model.supported_effort_levels.iter().any(|effort| { + open_ai::ReasoningEffort::from_str(&effort.value) + .is_ok_and(|effort| effort == open_ai::ReasoningEffort::None) + }); + let request = into_open_ai_response( + request, + &self.model.id.0, + self.model.supports_parallel_tool_calls, + true, + None, + None, + supports_none_reasoning_effort, + ); + let compact_request = open_ai::responses::CompactRequest { + model: request.model.clone(), + input: request.input, + }; + let http_client = self.http_client.clone(); + let token_provider = self.token_provider.clone(); + let auth_context = token_provider.auth_context(cx); + let future = self.request_limiter.run(async move { + let PerformLlmCompletionResponse { + response, + includes_status_messages, + } = Self::perform_llm_compaction( + &http_client, + &*token_provider, + auth_context, + app_version, + CompletionBody { + thread_id, + prompt_id, + provider: cloud_llm_client::LanguageModelProvider::OpenAi, + model: request.model, + provider_request: serde_json::to_value(compact_request).map_err(|error| { + LanguageModelCompletionError::SerializeRequest { + provider: provider_name.clone(), + error, + } + })?, + }, + ) + .await?; + + let events = response_lines::( + response, + includes_status_messages, + ); + futures::pin_mut!(events); + while let Some(event) = events.next().await { + match event.map_err(|error| error.into_completion_error(provider_name.clone()))? { + CompletionEvent::Event(response) => { + return Ok(CompactionContent::ProviderWindow { + items: response.output.into(), + }); + } + CompletionEvent::Status(_) => {} + } + } + + Err(LanguageModelCompletionError::StreamEndedUnexpectedly { + provider: provider_name, + }) + }); + future.boxed() + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_effort_levels diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 18798a54b17ad7..c52dbe88bd9f93 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -255,6 +255,7 @@ pub fn into_open_ai_response( let service_tier = service_tier_for(speed); + let mut provider_items = Vec::new(); let mut input_items = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); let mut tool_use_kinds_by_id = HashMap::default(); @@ -264,6 +265,7 @@ pub fn into_open_ai_response( index, &mut replayed_reasoning_item_indexes, &mut tool_use_kinds_by_id, + &mut provider_items, &mut input_items, ); } @@ -331,7 +333,7 @@ pub fn into_open_ai_response( ResponseRequest { model: model_id.into(), instructions: None, - input: input_items, + input: crate::responses::ResponseInput::new(provider_items, input_items), store: Some(false), include, stream, @@ -366,6 +368,7 @@ fn append_message_to_response_items( index: usize, replayed_reasoning_item_indexes: &mut HashMap, tool_use_kinds_by_id: &mut HashMap, + provider_items: &mut Vec, input_items: &mut Vec, ) { let mut content_parts: Vec = Vec::new(); @@ -412,6 +415,14 @@ fn append_message_to_response_items( encrypted_content, })); } + MessageContent::Compaction(CompactionContent::ProviderWindow { items }) => { + content_parts.clear(); + input_items.clear(); + replayed_reasoning_item_indexes.clear(); + tool_use_kinds_by_id.clear(); + provider_items.clear(); + provider_items.extend(items.iter().cloned()); + } // Summary compaction blocks come from other providers, and a // Pending block is a streaming-only UI signal; neither is replayed. MessageContent::Compaction( @@ -3994,6 +4005,68 @@ mod tests { })); } + #[test] + fn into_open_ai_response_prepends_provider_input_unchanged() { + let provider_input = json!([ + { + "type": "message", + "role": "user", + "content": "Retained user context.", + "provider_extension": {"preserve": true} + }, + { + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + } + ]); + let request = LanguageModelRequest { + messages: vec![ + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Compaction( + CompactionContent::ProviderWindow { + items: provider_input.as_array().unwrap().clone().into(), + }, + )], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Continue.".into())], + cache: false, + reasoning_details: None, + }, + ], + ..Default::default() + }; + + let response = into_open_ai_response(request, "gpt-5.4", true, true, None, None, false); + + assert_eq!( + serde_json::to_value(&response).unwrap()["input"], + json!([ + { + "type": "message", + "role": "user", + "content": "Retained user context.", + "provider_extension": {"preserve": true} + }, + { + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Continue."}] + } + ]) + ); + } + #[test] fn into_open_ai_response_maps_compact_at_tokens_to_context_management() { let request = LanguageModelRequest { diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index f508a2746057da..1dd7db325d7901 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -3,7 +3,7 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, ser::SerializeSeq as _}; use serde_json::Value; use std::sync::Arc; @@ -14,8 +14,8 @@ pub struct Request { pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub input: Vec, + #[serde(skip_serializing_if = "ResponseInput::is_empty")] + pub input: ResponseInput, #[serde(skip_serializing_if = "Vec::is_empty")] pub include: Vec, #[serde(default)] @@ -44,6 +44,61 @@ pub struct Request { pub context_management: Option>, } +#[derive(Serialize, Debug)] +pub struct CompactRequest { + pub model: String, + pub input: ResponseInput, +} + +#[derive(Deserialize, Debug)] +pub struct CompactedResponse { + pub id: String, + pub created_at: u64, + pub object: String, + pub output: Vec, + pub usage: ResponseUsage, +} + +#[derive(Debug, Default)] +pub struct ResponseInput { + provider_items: Vec, + generated_items: Vec, +} + +impl ResponseInput { + pub fn new(provider_items: Vec, generated_items: Vec) -> Self { + Self { + provider_items, + generated_items, + } + } + + pub fn is_empty(&self) -> bool { + self.provider_items.is_empty() && self.generated_items.is_empty() + } + + pub fn retain(&mut self, predicate: impl FnMut(&ResponseInputItem) -> bool) { + self.generated_items.retain(predicate); + } +} + +impl Serialize for ResponseInput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut sequence = serializer + .serialize_seq(Some(self.provider_items.len() + self.generated_items.len()))?; + for item in &self.provider_items { + sequence.serialize_element(item)?; + } + for item in &self.generated_items { + sequence.serialize_element(item)?; + } + sequence.end() + } +} + /// Server-side context management configuration. /// /// @@ -546,6 +601,45 @@ pub struct ResponseCustomToolCall { pub input: String, } +pub async fn compact_response( + client: &dyn HttpClient, + provider_name: &str, + api_url: &str, + api_key: &str, + request: CompactRequest, + extra_headers: &CustomHeaders, +) -> Result { + let request = HttpRequest::builder() + .method(Method::POST) + .uri(format!("{api_url}/responses/compact")) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key.trim())) + .extra_headers(extra_headers) + .body(AsyncBody::from( + serde_json::to_string(&request).map_err(|error| RequestError::Other(error.into()))?, + )) + .map_err(|error| RequestError::Other(error.into()))?; + + let mut response = client.send(request).await?; + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| RequestError::Other(error.into()))?; + + if response.status().is_success() { + serde_json::from_str(&body).map_err(|error| RequestError::Other(error.into())) + } else { + Err(RequestError::HttpResponseError { + provider: provider_name.to_owned(), + status_code: response.status(), + body, + headers: response.headers().clone(), + }) + } +} + pub async fn stream_response( client: &dyn HttpClient, provider_name: &str, @@ -732,3 +826,42 @@ pub async fn stream_response( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn compacted_response_preserves_canonical_output_items() { + let output = vec![ + json!({ + "type": "message", + "role": "user", + "content": "Retained user context.", + "provider_extension": {"preserve": true} + }), + json!({ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + }), + ]; + let response: CompactedResponse = serde_json::from_value(json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": &output, + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + })) + .unwrap(); + + assert_eq!(response.output, output); + } +} From 955595d42c7a8a155fe704151b3731eb77fd9b7e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 17 Jul 2026 13:50:59 -0600 Subject: [PATCH 02/12] Complete explicit OpenAI compaction support --- .../language_models/src/provider/open_ai.rs | 10 +- crates/language_models_cloud/Cargo.toml | 2 + .../src/language_models_cloud.rs | 236 +++++++++++++++- crates/open_ai/Cargo.toml | 1 + crates/open_ai/src/completion.rs | 39 +++ crates/open_ai/src/responses.rs | 255 +++++++++++++++++- 6 files changed, 530 insertions(+), 13 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 44511f7000a104..bf5e60bc2dac9d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -566,15 +566,15 @@ impl LanguageModel for OpenAiLanguageModel { .supported_reasoning_efforts() .contains(&open_ai::ReasoningEffort::None), ); - let request = CompactRequest { - model: request.model, - input: request.input, - }; + let request = request.into_compact_request(); let response = self.compact_response(request, cx); async move { let response = response.await?; + let items = response + .into_compaction_items() + .map_err(LanguageModelCompletionError::Other)?; Ok(CompactionContent::ProviderWindow { - items: response.output.into(), + items: items.into(), }) } .boxed() diff --git a/crates/language_models_cloud/Cargo.toml b/crates/language_models_cloud/Cargo.toml index 2e408e66c7165d..571d8e86ebc6db 100644 --- a/crates/language_models_cloud/Cargo.toml +++ b/crates/language_models_cloud/Cargo.toml @@ -29,4 +29,6 @@ serde_json.workspace = true thiserror.workspace = true [dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 35b7b4cb5b8ae8..4cce4e4611fb4a 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -457,10 +457,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel { + let items = response + .into_compaction_items() + .map_err(LanguageModelCompletionError::Other)?; return Ok(CompactionContent::ProviderWindow { - items: response.output.into(), + items: items.into(), }); } CompletionEvent::Status(_) => {} @@ -1104,8 +1104,158 @@ pub fn response_lines( #[cfg(test)] mod tests { use super::*; + use http_client::FakeHttpClient; use http_client::http::{HeaderMap, StatusCode}; - use language_model::LanguageModelCompletionError; + use language_model::{ + LanguageModelCompletionError, LanguageModelRequestMessage, MessageContent, Role, Speed, + }; + use serde_json::json; + use std::sync::Mutex; + + #[gpui::test] + async fn cloud_explicit_compaction_forwards_supported_request_fields( + cx: &mut gpui::TestAppContext, + ) { + let captured_request = Arc::new(Mutex::new(None)); + let captured_request_for_handler = captured_request.clone(); + let http_client = FakeHttpClient::create(move |request| { + let captured_request = captured_request_for_handler.clone(); + async move { + let method = request.method().clone(); + let uri = request.uri().to_string(); + let authorization = request + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let requested_status_messages = request + .headers() + .contains_key(CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME); + let requested_stream_end = request + .headers() + .contains_key(CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME); + let mut body = request.into_body(); + let mut body_text = String::new(); + body.read_to_string(&mut body_text).await?; + *captured_request.lock().unwrap() = Some(( + method, + uri, + authorization, + requested_status_messages, + requested_stream_end, + body_text, + )); + + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from(format!( + "{}\n", + json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": [{ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + }], + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + }) + )))?) + } + }); + let model = cloud_test_model(http_client); + let request = compact_test_request(); + + let content = model.compact(request, &cx.to_async()).await.unwrap(); + + assert_eq!( + content, + CompactionContent::ProviderWindow { + items: vec![json!({ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + })] + .into() + } + ); + let (method, uri, authorization, requested_status_messages, requested_stream_end, body) = + captured_request.lock().unwrap().take().unwrap(); + assert_eq!(method, Method::POST); + assert_eq!(uri, "http://test.example/completions/compact?"); + assert_eq!(authorization.as_deref(), Some("Bearer test-token")); + assert!(!requested_status_messages); + assert!(!requested_stream_end); + let body = serde_json::from_str::(&body).unwrap(); + assert_eq!(body["thread_id"], "thread-123"); + assert_eq!(body["provider"], "open_ai"); + assert_eq!(body["model"], "gpt-5.4"); + assert_eq!( + body["provider_request"], + json!({ + "model": "gpt-5.4", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "Retain this context." + }] + }], + "prompt_cache_key": "thread-123", + "service_tier": "priority" + }) + ); + } + + #[gpui::test] + async fn cloud_explicit_compaction_rejects_output_without_compaction_item( + cx: &mut gpui::TestAppContext, + ) { + let http_client = FakeHttpClient::create(|_| async move { + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from(format!( + "{}\n", + json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": [{ + "type": "message", + "role": "assistant", + "content": "This is not an opaque compaction item." + }], + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + }) + )))?) + }); + let model = cloud_test_model(http_client); + + let error = model + .compact(compact_test_request(), &cx.to_async()) + .await + .unwrap_err(); + + assert!( + matches!(&error, LanguageModelCompletionError::Other(_)), + "expected invalid canonical output to be rejected, got {error:?}" + ); + assert!(error.to_string().contains("compaction item")); + } #[test] fn test_api_error_conversion_with_upstream_http_error() { @@ -1279,4 +1429,76 @@ mod tests { "Expected DeserializeResponse, got: {deserialize:?}" ); } + + fn compact_test_request() -> LanguageModelRequest { + LanguageModelRequest { + thread_id: Some("thread-123".to_string()), + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Retain this context.".to_string())], + cache: false, + reasoning_details: None, + }], + speed: Some(Speed::Fast), + ..Default::default() + } + } + + fn cloud_test_model( + http_client: Arc, + ) -> CloudLanguageModel { + CloudLanguageModel { + id: LanguageModelId::from("gpt-5.4".to_string()), + model: Arc::new(cloud_llm_client::LanguageModel { + provider: cloud_llm_client::LanguageModelProvider::OpenAi, + id: cloud_llm_client::LanguageModelId(Arc::from("gpt-5.4")), + display_name: "GPT-5.4".to_string(), + is_latest: true, + max_token_count: 1_000_000, + max_token_count_in_max_mode: None, + max_output_tokens: 128_000, + supports_tools: true, + supports_images: true, + supports_thinking: true, + supports_disabling_thinking: true, + supports_fast_mode: true, + supports_server_side_compaction: true, + supported_effort_levels: Vec::new(), + supports_streaming_tools: true, + supports_parallel_tool_calls: true, + is_disabled: false, + disabled_reason: None, + }), + token_provider: Arc::new(TestTokenProvider), + http_client, + app_version: None, + request_limiter: RateLimiter::new(4), + } + } + + struct TestTokenProvider; + + impl CloudLlmTokenProvider for TestTokenProvider { + type AuthContext = (); + + fn auth_context(&self, _cx: &impl AppContext) -> Self::AuthContext {} + + fn cached_token( + &self, + _auth_context: Self::AuthContext, + ) -> BoxFuture<'static, Result> { + async { Ok("test-token".to_string()) }.boxed() + } + + fn refresh_token( + &self, + _auth_context: Self::AuthContext, + ) -> BoxFuture<'static, Result> { + async { Ok("refreshed-test-token".to_string()) }.boxed() + } + + fn has_data_retention_consent(&self, _cx: &impl AppContext) -> bool { + false + } + } } diff --git a/crates/open_ai/Cargo.toml b/crates/open_ai/Cargo.toml index 5083e97c560147..8b89be9bada9b0 100644 --- a/crates/open_ai/Cargo.toml +++ b/crates/open_ai/Cargo.toml @@ -30,4 +30,5 @@ strum.workspace = true thiserror.workspace = true [dev-dependencies] +http_client = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index c52dbe88bd9f93..dc5e2045cca0c2 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -4067,6 +4067,45 @@ mod tests { ); } + #[test] + fn open_ai_response_converts_to_compact_request_with_supported_controls() { + let request = LanguageModelRequest { + thread_id: Some("thread-123".to_string()), + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Retain this context.".into())], + cache: false, + reasoning_details: None, + }], + speed: Some(Speed::Fast), + compact_at_tokens: Some(100_000), + ..Default::default() + }; + + let mut response_request = + into_open_ai_response(request, "gpt-5.4", true, true, None, None, false); + response_request.instructions = Some("Preserve implementation details.".to_string()); + let compact_request = response_request.into_compact_request(); + + assert_eq!( + serde_json::to_value(compact_request).unwrap(), + json!({ + "model": "gpt-5.4", + "instructions": "Preserve implementation details.", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "Retain this context." + }] + }], + "prompt_cache_key": "thread-123", + "service_tier": "priority" + }) + ); + } + #[test] fn into_open_ai_response_maps_compact_at_tokens_to_context_management() { let request = LanguageModelRequest { diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 1dd7db325d7901..bed470fff0d792 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -44,10 +44,28 @@ pub struct Request { pub context_management: Option>, } +impl Request { + pub fn into_compact_request(self) -> CompactRequest { + CompactRequest { + model: self.model, + instructions: self.instructions, + input: self.input, + prompt_cache_key: self.prompt_cache_key, + service_tier: self.service_tier, + } + } +} + #[derive(Serialize, Debug)] pub struct CompactRequest { pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, pub input: ResponseInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, } #[derive(Deserialize, Debug)] @@ -59,6 +77,24 @@ pub struct CompactedResponse { pub usage: ResponseUsage, } +impl CompactedResponse { + pub fn into_compaction_items(self) -> Result> { + if self.output.is_empty() { + return Err(anyhow!("OpenAI returned an empty compaction output")); + } + if !self.output.iter().any(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(|item_type| item_type == "compaction") + }) { + return Err(anyhow!( + "OpenAI compaction output did not contain a compaction item" + )); + } + Ok(self.output) + } +} + #[derive(Debug, Default)] pub struct ResponseInput { provider_items: Vec, @@ -830,7 +866,149 @@ pub async fn stream_response( #[cfg(test)] mod tests { use super::*; + use futures::executor::block_on; + use http_client::FakeHttpClient; use serde_json::json; + use std::sync::{Arc, Mutex}; + + #[test] + fn compact_response_posts_supported_request_fields() { + let captured_request = Arc::new(Mutex::new(None)); + let captured_request_for_handler = captured_request.clone(); + let http_client = FakeHttpClient::create(move |request| { + let captured_request = captured_request_for_handler.clone(); + async move { + let method = request.method().clone(); + let uri = request.uri().to_string(); + let authorization = request + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let mut body = request.into_body(); + let mut body_text = String::new(); + body.read_to_string(&mut body_text).await?; + *captured_request.lock().unwrap() = Some((method, uri, authorization, body_text)); + + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from( + json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": [{ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + }], + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + }) + .to_string(), + ))?) + } + }); + let response = block_on(compact_response( + http_client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + "secret", + compact_test_request(), + &CustomHeaders::default(), + )) + .unwrap(); + + assert_eq!( + response.into_compaction_items().unwrap(), + vec![json!({ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + })] + ); + let (method, uri, authorization, body) = captured_request.lock().unwrap().take().unwrap(); + assert_eq!(method, Method::POST); + assert_eq!(uri, "https://api.openai.com/v1/responses/compact"); + assert_eq!(authorization.as_deref(), Some("Bearer secret")); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ + "model": "gpt-5.4", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "Retain this context." + }] + }], + "prompt_cache_key": "thread-123", + "service_tier": "priority" + }) + ); + } + + #[test] + fn compact_response_reports_http_and_deserialization_errors() { + let http_client = FakeHttpClient::create(|_| async move { + Ok(http_client::Response::builder() + .status(429) + .header("retry-after", "5") + .body(AsyncBody::from("rate limited"))?) + }); + + let error = block_on(compact_response( + http_client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + "secret", + compact_test_request(), + &CustomHeaders::default(), + )) + .unwrap_err(); + + match error { + RequestError::HttpResponseError { + provider, + status_code, + body, + headers, + } => { + assert_eq!(provider, "OpenAI"); + assert_eq!(status_code, 429); + assert_eq!(body, "rate limited"); + assert_eq!(headers["retry-after"], "5"); + } + error => panic!("expected an HTTP response error, got {error:?}"), + } + + let http_client = FakeHttpClient::create(|_| async move { + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from("not valid JSON"))?) + }); + + let error = block_on(compact_response( + http_client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + "secret", + compact_test_request(), + &CustomHeaders::default(), + )) + .unwrap_err(); + + assert!( + matches!(error, RequestError::Other(_)), + "expected malformed JSON to produce a request error, got {error:?}" + ); + } #[test] fn compacted_response_preserves_canonical_output_items() { @@ -862,6 +1040,81 @@ mod tests { })) .unwrap(); - assert_eq!(response.output, output); + assert_eq!(response.into_compaction_items().unwrap(), output); + } + + #[test] + fn compacted_response_rejects_output_without_compaction_item() { + let response: CompactedResponse = serde_json::from_value(json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": [{ + "type": "message", + "role": "user", + "content": "Retained user context." + }], + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + })) + .unwrap(); + + assert!( + response + .into_compaction_items() + .unwrap_err() + .to_string() + .contains("compaction item") + ); + } + + #[test] + fn compacted_response_rejects_empty_output() { + let response: CompactedResponse = serde_json::from_value(json!({ + "id": "resp_compact", + "created_at": 1_700_000_000, + "object": "response.compaction", + "output": [], + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 20}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 5}, + "total_tokens": 110 + } + })) + .unwrap(); + + assert!( + response + .into_compaction_items() + .unwrap_err() + .to_string() + .contains("empty") + ); + } + + fn compact_test_request() -> CompactRequest { + CompactRequest { + model: "gpt-5.4".to_string(), + instructions: None, + input: ResponseInput::new( + Vec::new(), + vec![ResponseInputItem::Message(ResponseMessageItem { + role: Role::User, + content: vec![ResponseInputContent::Text { + text: "Retain this context.".to_string(), + }], + phase: None, + })], + ), + prompt_cache_key: Some("thread-123".to_string()), + service_tier: Some(ServiceTier::Priority), + } } } From bbd0f4fe0eb5822a7b897447825508b224207cdd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 19 Jul 2026 17:31:43 -0600 Subject: [PATCH 03/12] Separate compacted context from stream updates --- crates/anthropic/src/completion.rs | 146 +++++++++++--- crates/language_model/src/language_model.rs | 2 +- .../src/language_model_core.rs | 12 +- crates/language_model_core/src/request.rs | 60 ++++-- .../language_models/src/provider/bedrock.rs | 13 +- .../language_models/src/provider/open_ai.rs | 27 +-- .../src/provider/open_ai_compatible.rs | 13 +- .../src/provider/openai_subscribed.rs | 7 +- .../language_models/src/provider/opencode.rs | 7 +- .../src/language_models_cloud.rs | 45 +++-- crates/open_ai/src/completion.rs | 190 +++++++++++------- crates/open_ai/src/responses.rs | 127 ++++++++++-- 12 files changed, 464 insertions(+), 185 deletions(-) diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 640a5a28273840..e1408ceb830f6f 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ - CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, + CompactedContext, CompactionUpdate, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage, @@ -159,19 +159,13 @@ fn to_anthropic_content(content: MessageContent) -> Result { + MessageContent::Compaction(CompactedContext::Summary { content }) => { Ok(Some(RequestContent::Compaction { - content, + content: Some(content), cache_control: None, })) } - // Provider-native compaction blocks come from other providers, and a - // Pending block is a streaming-only UI signal; neither is replayed. - MessageContent::Compaction( - CompactionContent::Encrypted { .. } - | CompactionContent::ProviderWindow { .. } - | CompactionContent::Pending, - ) => Ok(None), + MessageContent::Compaction(CompactedContext::ProviderState(_)) => Ok(None), } } @@ -360,6 +354,7 @@ pub fn into_anthropic( pub struct AnthropicEventMapper { tool_uses_by_index: HashMap, + compaction_summaries_by_index: HashMap, usage: Usage, stop_reason: StopReason, provider_name: LanguageModelProviderName, @@ -369,6 +364,7 @@ impl AnthropicEventMapper { pub fn new(provider_name: LanguageModelProviderName) -> Self { Self { tool_uses_by_index: HashMap::default(), + compaction_summaries_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, provider_name, @@ -424,9 +420,19 @@ impl AnthropicEventMapper { Vec::new() } ResponseContent::Compaction { content } => { - vec![Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Summary { content }, - ))] + let mut events = vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Started, + ))]; + let summary = self.compaction_summaries_by_index.entry(index).or_default(); + if let Some(content) = content + && !content.is_empty() + { + summary.push_str(&content); + events.push(Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::SummaryDelta(content), + ))); + } + events } }, Event::ContentBlockDelta { index, delta } => match delta { @@ -446,8 +452,17 @@ impl AnthropicEventMapper { })] } ContentDelta::CompactionDelta { content } => { + let Some(content) = content.filter(|content| !content.is_empty()) else { + return Vec::new(); + }; + let Some(summary) = self.compaction_summaries_by_index.get_mut(&index) else { + return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "Anthropic streamed a compaction delta before starting its content block" + )))]; + }; + summary.push_str(&content); vec![Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Summary { content }, + CompactionUpdate::SummaryDelta(content), ))] } ContentDelta::InputJsonDelta { partial_json } => { @@ -477,7 +492,18 @@ impl AnthropicEventMapper { } }, Event::ContentBlockStop { index } => { - if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { + if let Some(summary) = self.compaction_summaries_by_index.remove(&index) { + if summary.is_empty() { + return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "Anthropic returned an empty compaction summary" + )))]; + } + vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Finished(CompactedContext::Summary { + content: summary.into(), + }), + ))] + } else if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { let input_json = tool_use.input_json.trim(); let event_result = match parse_tool_arguments(input_json) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( @@ -1023,8 +1049,8 @@ mod tests { #[test] fn test_compaction_content_replayed_as_compaction_block() { let result = request_with_assistant_content(vec![ - MessageContent::Compaction(CompactionContent::Summary { - content: Some("Summary of the conversation so far.".into()), + MessageContent::Compaction(CompactedContext::Summary { + content: "Summary of the conversation so far.".into(), }), MessageContent::Text("Response".to_string()), ]); @@ -1051,19 +1077,32 @@ mod tests { let start_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_start", "index": 0, - "content_block": { "type": "compaction", "content": null } + "content_block": { "type": "compaction", "content": "Summary " } })) .unwrap(); let delta_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_delta", "index": 0, - "delta": { "type": "compaction_delta", "content": "Summary chunk" } + "delta": { "type": "compaction_delta", "content": "in " } + })) + .unwrap(); + let second_delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "compaction_delta", "content": "chunks" } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 })) .unwrap(); let mut events = Vec::new(); events.extend(mapper.map_event(start_event)); events.extend(mapper.map_event(delta_event)); + events.extend(mapper.map_event(second_delta_event)); + events.extend(mapper.map_event(stop_event)); let events = events .into_iter() .collect::, _>>() @@ -1072,16 +1111,73 @@ mod tests { assert_eq!( events, vec![ - LanguageModelCompletionEvent::Compaction(CompactionContent::Summary { - content: None - }), - LanguageModelCompletionEvent::Compaction(CompactionContent::Summary { - content: Some("Summary chunk".into()) - }), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "Summary ".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "in ".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "chunks".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::Summary { + content: "Summary in chunks".into() + } + )), ] ); } + #[test] + fn test_event_mapper_rejects_empty_compaction_summary() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": null } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 + })) + .unwrap(); + + assert_eq!( + mapper + .map_event(start_event) + .into_iter() + .collect::, _>>() + .unwrap(), + vec![LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Started + )] + ); + let error = mapper.map_event(stop_event).pop().unwrap().unwrap_err(); + assert!(error.to_string().contains("empty compaction summary")); + } + + #[test] + fn test_event_mapper_rejects_compaction_delta_before_start() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "compaction_delta", "content": "Summary chunk" } + })) + .unwrap(); + + let error = mapper.map_event(delta_event).pop().unwrap().unwrap_err(); + + assert!( + error + .to_string() + .contains("compaction delta before starting") + ); + } + #[test] fn test_usage_iterations_parsed_from_message_delta() { let event: Event = serde_json::from_value(serde_json::json!({ diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 2f448c5883ce37..fbf33c31256d69 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -139,7 +139,7 @@ pub trait LanguageModel: Send + Sync { &self, _request: LanguageModelRequest, _cx: &AsyncApp, - ) -> BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { let provider = self.provider_name(); async move { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index e514a0155a80f0..eb84806a160a37 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -59,7 +59,17 @@ pub enum LanguageModelCompletionEvent { }, ReasoningDetails(serde_json::Value), UsageUpdate(TokenUsage), - Compaction(CompactionContent), + Compaction(CompactionUpdate), +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub enum CompactionUpdate { + /// A streamed response has started producing replacement context. + Started, + /// A chunk of a natural-language summary, suitable for incremental display. + SummaryDelta(Arc), + /// The complete context to persist and use in subsequent requests. + Finished(CompactedContext), } impl LanguageModelCompletionEvent { diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 46360bb3f18513..b28c819b2efab3 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -4,7 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::role::Role; use crate::{ - LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, + LanguageModelProviderId, LanguageModelToolUse, LanguageModelToolUseId, + LanguageModelToolUseInput, SharedString, }; /// Dimensions of a `LanguageModelImage` @@ -262,24 +263,51 @@ pub enum MessageContent { Image(LanguageModelImage), ToolUse(LanguageModelToolUse), ToolResult(LanguageModelToolResult), - Compaction(CompactionContent), + Compaction(CompactedContext), } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] -pub enum CompactionContent { - Pending, - Summary { - content: Option>, - }, - Encrypted { - id: Option>, - encrypted_content: Arc, - }, - /// A provider's canonical replacement window, which must be replayed - /// unchanged and supersedes all request messages that precede it. - ProviderWindow { - items: Arc<[serde_json::Value]>, - }, +pub enum CompactedContext { + Summary { content: Arc }, + ProviderState(ProviderCompactionState), +} + +/// Opaque context produced by a provider's native compaction mechanism. +/// +/// Only the provider identified by `provider_id` may interpret `payload`. +/// `format` lets that provider evolve its representation without exposing it +/// through the shared language model API. +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] +pub struct ProviderCompactionState { + provider_id: LanguageModelProviderId, + format: SharedString, + payload: Arc, +} + +impl ProviderCompactionState { + pub fn new( + provider_id: LanguageModelProviderId, + format: impl Into, + payload: impl Into>, + ) -> Self { + Self { + provider_id, + format: format.into(), + payload: payload.into(), + } + } + + pub fn provider_id(&self) -> &LanguageModelProviderId { + &self.provider_id + } + + pub fn format(&self) -> &str { + &self.format + } + + pub fn payload(&self) -> &str { + &self.payload + } } impl MessageContent { diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index b686a3d11f3148..955e2f29bfc50c 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1466,7 +1466,7 @@ impl LanguageModel for BedrockMantleModel { match self.model.protocol() { MantleProtocol::Responses => { - let request = into_open_ai_response( + let request = match into_open_ai_response( request, &model_id, self.model.supports_tools(), @@ -1474,7 +1474,10 @@ impl LanguageModel for BedrockMantleModel { max_output_tokens, mantle_default_reasoning_effort(&self.model), self.model.supports_thinking(), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_response(request, cx); async move { let mapper = OpenAiResponseEventMapper::new(); @@ -2693,7 +2696,8 @@ mod tests { Some(MantleModel::Grok4_3.max_output_tokens()), mantle_default_reasoning_effort(&MantleModel::Grok4_3), MantleModel::Grok4_3.supports_thinking(), - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&request).unwrap()["reasoning"], @@ -2744,7 +2748,8 @@ mod tests { Some(128_000), Some(ReasoningEffort::Medium), false, - ); + ) + .unwrap(); assert!(request.context_management.is_some()); strip_unsupported_mantle_response_fields(&mut request); diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index bf5e60bc2dac9d..70c64d110a55ca 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyConfiguration, ApiKeyState, AuthenticateError, CompactionContent, EnvVar, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, CompactedContext, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, @@ -544,7 +544,7 @@ impl LanguageModel for OpenAiLanguageModel { &self, mut request: LanguageModelRequest, cx: &AsyncApp, - ) -> BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { if !self.supports_explicit_compaction() { return async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( @@ -555,7 +555,7 @@ impl LanguageModel for OpenAiLanguageModel { } normalize_open_ai_response_thinking_effort(&mut request, &self.model); - let request = into_open_ai_response( + let request = match into_open_ai_response( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -565,17 +565,17 @@ impl LanguageModel for OpenAiLanguageModel { self.model .supported_reasoning_efforts() .contains(&open_ai::ReasoningEffort::None), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request = request.into_compact_request(); let response = self.compact_response(request, cx); async move { let response = response.await?; - let items = response - .into_compaction_items() - .map_err(LanguageModelCompletionError::Other)?; - Ok(CompactionContent::ProviderWindow { - items: items.into(), - }) + response + .into_compacted_context() + .map_err(LanguageModelCompletionError::Other) } .boxed() } @@ -619,7 +619,7 @@ impl LanguageModel for OpenAiLanguageModel { } if self.model.uses_responses_api() { normalize_open_ai_response_thinking_effort(&mut request, &self.model); - let request = into_open_ai_response( + let request = match into_open_ai_response( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -629,7 +629,10 @@ impl LanguageModel for OpenAiLanguageModel { self.model .supported_reasoning_efforts() .contains(&open_ai::ReasoningEffort::None), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_response(request, cx); async move { let mapper = OpenAiResponseEventMapper::new(); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index ff67344ac5aba6..1c93e69c18eac8 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -445,7 +445,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { .boxed() } else { disable_response_thinking_for_none_effort(&mut request, &self.model); - let request = into_open_ai_response( + let request = match into_open_ai_response( request, &self.model.name, self.model.capabilities.parallel_tool_calls, @@ -453,7 +453,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.max_output_tokens(), default_thinking_reasoning_effort(&self.model), supports_none_reasoning_effort(&self.model), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_response(request, cx); async move { let mapper = OpenAiResponseEventMapper::new(); @@ -635,7 +638,8 @@ mod tests { model.max_output_tokens, default_thinking_reasoning_effort(&model), supports_none_reasoning_effort(&model), - ); + ) + .unwrap(); let serialized = serde_json::to_value(request).unwrap(); assert_eq!( @@ -664,7 +668,8 @@ mod tests { model.max_output_tokens, default_thinking_reasoning_effort(&model), supports_none_reasoning_effort(&model), - ); + ) + .unwrap(); let serialized = serde_json::to_value(request).unwrap(); assert_eq!(serialized.get("reasoning"), None); diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 4e2dee2075cac9..8fb936b976e1ef 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -519,7 +519,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), // unlike the public OpenAI Responses API. Pass `None` so the field is // omitted from the serialized request body entirely. - let mut responses_request = into_open_ai_response( + let mut responses_request = match into_open_ai_response( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -529,7 +529,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { self.model .supported_reasoning_efforts() .contains(&ReasoningEffort::None), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; responses_request.store = Some(false); // The Codex backend requires system messages to be in the top-level diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 6604995ffee14a..5b868256f66124 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -725,7 +725,7 @@ impl LanguageModel for OpenCodeLanguageModel { .model .supported_reasoning_effort_levels() .is_some_and(|levels| levels.contains(&ReasoningEffort::None)); - let response_request = into_open_ai_response( + let response_request = match into_open_ai_response( request, self.model.id(), true, @@ -733,7 +733,10 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.max_output_tokens(self.subscription), None, supports_none_reasoning_effort, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_openai_response(response_request, http_client, extra_headers, cx); async move { diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 4cce4e4611fb4a..d798166e34e1ba 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -19,7 +19,7 @@ use http_client::{ AsyncBody, HttpClient, HttpClientWithUrl, HttpRequestExt, Method, Response, StatusCode, }; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactionContent, DisabledReason, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactedContext, DisabledReason, GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, @@ -429,7 +429,7 @@ impl LanguageModel for CloudLanguageModel BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { if !self.supports_explicit_compaction() { return async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( @@ -448,7 +448,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let compact_request = request.into_compact_request(); let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); @@ -493,12 +496,9 @@ impl LanguageModel for CloudLanguageModel { - let items = response - .into_compaction_items() - .map_err(LanguageModelCompletionError::Other)?; - return Ok(CompactionContent::ProviderWindow { - items: items.into(), - }); + return response + .into_compacted_context() + .map_err(LanguageModelCompletionError::Other); } CompletionEvent::Status(_) => {} } @@ -676,7 +676,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if enable_thinking && let Some(effort) = effort { request.reasoning = Some(open_ai::responses::ReasoningConfig { @@ -1175,16 +1178,16 @@ mod tests { let content = model.compact(request, &cx.to_async()).await.unwrap(); + let CompactedContext::ProviderState(state) = content else { + panic!("expected provider compaction state"); + }; assert_eq!( - content, - CompactionContent::ProviderWindow { - items: vec![json!({ - "type": "compaction", - "id": "cmp_manual", - "encrypted_content": "opaque-state" - })] - .into() - } + open_ai::responses::provider_compaction_items(&state).unwrap(), + Some(vec![json!({ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + })]) ); let (method, uri, authorization, requested_status_messages, requested_stream_end, body) = captured_request.lock().unwrap().take().unwrap(); diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index dc5e2045cca0c2..e9977a43805a57 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -2,7 +2,7 @@ use anyhow::{Result, anyhow}; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ - CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, + CompactedContext, CompactionUpdate, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, @@ -14,13 +14,14 @@ use std::pin::Pin; use std::sync::Arc; use crate::responses::{ - ContextManagement, Request as ResponseRequest, ResponseCompactionItem, - ResponseCustomToolCallItem, ResponseCustomToolCallOutputItem, ResponseError, - ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem, - ResponseIncludable, ResponseInputContent, ResponseInputItem, ResponseMessageItem, - ResponseOutputItem, ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem, + ContextManagement, Request as ResponseRequest, ResponseCustomToolCallItem, + ResponseCustomToolCallOutputItem, ResponseError, ResponseFunctionCallItem, + ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem, ResponseIncludable, + ResponseInputContent, ResponseInputItem, ResponseMessageItem, ResponseOutputItem, + ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem, ResponseReasoningSummaryPart, ResponseSummary as ResponsesSummary, ResponseUsage as ResponsesUsage, StreamEvent as ResponsesStreamEvent, + provider_compaction_items, provider_compaction_state_from_items, }; use crate::{ FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, @@ -235,7 +236,7 @@ pub fn into_open_ai_response( max_output_tokens: Option, default_reasoning_effort: Option, supports_none_reasoning_effort: bool, -) -> ResponseRequest { +) -> Result { let stream = !model_id.starts_with("o1-"); let LanguageModelRequest { @@ -267,7 +268,7 @@ pub fn into_open_ai_response( &mut tool_use_kinds_by_id, &mut provider_items, &mut input_items, - ); + )?; } let tools: Vec<_> = tools @@ -330,7 +331,7 @@ pub fn into_open_ai_response( Vec::new() }; - ResponseRequest { + Ok(ResponseRequest { model: model_id.into(), instructions: None, input: crate::responses::ResponseInput::new(provider_items, input_items), @@ -360,7 +361,7 @@ pub fn into_open_ai_response( service_tier, context_management: compact_at_tokens .map(|compact_threshold| vec![ContextManagement::Compaction { compact_threshold }]), - } + }) } fn append_message_to_response_items( @@ -370,7 +371,7 @@ fn append_message_to_response_items( tool_use_kinds_by_id: &mut HashMap, provider_items: &mut Vec, input_items: &mut Vec, -) { +) -> Result<()> { let mut content_parts: Vec = Vec::new(); let LanguageModelRequestMessage { @@ -399,35 +400,17 @@ fn append_message_to_response_items( push_response_text_part(&role, text, &mut content_parts); } MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) => {} - MessageContent::Compaction(CompactionContent::Encrypted { - id, - encrypted_content, - }) => { - flush_response_parts( - &role, - index, - phase.as_deref(), - &mut content_parts, - input_items, - ); - input_items.push(ResponseInputItem::Compaction(ResponseCompactionItem { - id, - encrypted_content, - })); - } - MessageContent::Compaction(CompactionContent::ProviderWindow { items }) => { - content_parts.clear(); - input_items.clear(); - replayed_reasoning_item_indexes.clear(); - tool_use_kinds_by_id.clear(); - provider_items.clear(); - provider_items.extend(items.iter().cloned()); + MessageContent::Compaction(CompactedContext::ProviderState(state)) => { + if let Some(items) = provider_compaction_items(&state)? { + content_parts.clear(); + input_items.clear(); + replayed_reasoning_item_indexes.clear(); + tool_use_kinds_by_id.clear(); + provider_items.clear(); + provider_items.extend(items); + } } - // Summary compaction blocks come from other providers, and a - // Pending block is a streaming-only UI signal; neither is replayed. - MessageContent::Compaction( - CompactionContent::Summary { .. } | CompactionContent::Pending, - ) => {} + MessageContent::Compaction(CompactedContext::Summary { .. }) => {} MessageContent::Image(image) => { push_response_image_part(&role, image, &mut content_parts); } @@ -525,6 +508,7 @@ fn append_message_to_response_items( &mut content_parts, input_items, ); + Ok(()) } #[derive(Clone, Copy)] @@ -943,7 +927,7 @@ impl OpenAiResponseEventMapper { } ResponseOutputItem::Compaction(_) => { events.push(Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Pending, + CompactionUpdate::Started, ))); } ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {} @@ -1125,12 +1109,15 @@ impl OpenAiResponseEventMapper { } } ResponseOutputItem::Compaction(compaction) => { - vec![Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Encrypted { - id: compaction.id, - encrypted_content: compaction.encrypted_content, - }, - ))] + match serde_json::to_value(ResponseInputItem::Compaction(compaction)) + .map_err(anyhow::Error::from) + .and_then(|item| provider_compaction_state_from_items(vec![item])) + { + Ok(state) => vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Finished(CompactedContext::ProviderState(state)), + ))], + Err(error) => vec![Err(LanguageModelCompletionError::Other(error))], + } } ResponseOutputItem::FunctionCall(_) | ResponseOutputItem::Unknown => Vec::new(), }, @@ -1839,7 +1826,8 @@ mod tests { Some(2048), Some(ReasoningEffort::Low), false, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); let expected = json!({ @@ -2022,7 +2010,8 @@ mod tests { }; let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false); + into_open_ai_response(request, "custom-model", false, false, None, None, false) + .unwrap(); let serialized = serde_json::to_value(response).unwrap(); assert_eq!( serialized, @@ -2121,7 +2110,8 @@ mod tests { None, Some(ReasoningEffort::Low), false, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2197,7 +2187,8 @@ mod tests { }; let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false); + into_open_ai_response(request, "custom-model", false, false, None, None, false) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2261,7 +2252,8 @@ mod tests { None, Some(ReasoningEffort::Medium), false, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!(serialized.get("reasoning"), None); @@ -2297,7 +2289,8 @@ mod tests { compact_at_tokens: None, }; - let response = into_open_ai_response(request, "gpt-5.4", true, true, None, None, true); + let response = + into_open_ai_response(request, "gpt-5.4", true, true, None, None, true).unwrap(); let serialized = serde_json::to_value(&response)?; assert_eq!( @@ -2431,7 +2424,8 @@ mod tests { None, Some(ReasoningEffort::Medium), true, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response)?; assert_eq!(serialized["reasoning"], json!({ "effort": "none" })); @@ -2470,7 +2464,8 @@ mod tests { None, Some(ReasoningEffort::Medium), true, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response)?; assert_eq!( @@ -2521,7 +2516,8 @@ mod tests { None, Some(ReasoningEffort::Medium), false, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2611,7 +2607,8 @@ mod tests { None, Some(ReasoningEffort::Medium), false, - ); + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2692,7 +2689,8 @@ mod tests { }; let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false); + into_open_ai_response(request, "custom-model", false, false, None, None, false) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -4024,11 +4022,12 @@ mod tests { messages: vec![ LanguageModelRequestMessage { role: Role::Assistant, - content: vec![MessageContent::Compaction( - CompactionContent::ProviderWindow { - items: provider_input.as_array().unwrap().clone().into(), - }, - )], + content: vec![MessageContent::Compaction(CompactedContext::ProviderState( + provider_compaction_state_from_items( + provider_input.as_array().unwrap().clone(), + ) + .unwrap(), + ))], cache: false, reasoning_details: None, }, @@ -4042,7 +4041,8 @@ mod tests { ..Default::default() }; - let response = into_open_ai_response(request, "gpt-5.4", true, true, None, None, false); + let response = + into_open_ai_response(request, "gpt-5.4", true, true, None, None, false).unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["input"], @@ -4083,7 +4083,7 @@ mod tests { }; let mut response_request = - into_open_ai_response(request, "gpt-5.4", true, true, None, None, false); + into_open_ai_response(request, "gpt-5.4", true, true, None, None, false).unwrap(); response_request.instructions = Some("Preserve implementation details.".to_string()); let compact_request = response_request.into_compact_request(); @@ -4119,7 +4119,8 @@ mod tests { ..Default::default() }; - let response = into_open_ai_response(request, "gpt-5.1", true, true, None, None, false); + let response = + into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["context_management"], @@ -4139,7 +4140,8 @@ mod tests { ..Default::default() }; - let response = into_open_ai_response(request, "gpt-5.1", true, true, None, None, false); + let response = + into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); assert!( serde_json::to_value(&response) @@ -4150,15 +4152,18 @@ mod tests { } #[test] - fn into_open_ai_response_replays_encrypted_compaction_block() { + fn into_open_ai_response_replays_provider_compaction_block() { + let state = provider_compaction_state_from_items(vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })]) + .unwrap(); let request = LanguageModelRequest { messages: vec![LanguageModelRequestMessage { role: Role::Assistant, content: vec![ - MessageContent::Compaction(CompactionContent::Encrypted { - id: Some("cmp_1".into()), - encrypted_content: "encrypted-blob".into(), - }), + MessageContent::Compaction(CompactedContext::ProviderState(state)), MessageContent::Text("Done.".into()), ], cache: false, @@ -4167,7 +4172,8 @@ mod tests { ..Default::default() }; - let response = into_open_ai_response(request, "gpt-5.1", true, true, None, None, false); + let response = + into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["input"], @@ -4188,6 +4194,30 @@ mod tests { ); } + #[test] + fn into_open_ai_response_rejects_malformed_provider_compaction_state() { + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Compaction(CompactedContext::ProviderState( + language_model_core::ProviderCompactionState::new( + language_model_core::OPEN_AI_PROVIDER_ID, + crate::responses::COMPACTION_STATE_FORMAT, + "not valid JSON", + ), + ))], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let error = + into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap_err(); + + assert!(error.to_string().contains("expected ident")); + } + #[test] fn responses_stream_maps_compaction_output_item() { let item: ResponseOutputItem = serde_json::from_value(json!({ @@ -4214,11 +4244,17 @@ mod tests { assert_eq!( mapped, vec![ - LanguageModelCompletionEvent::Compaction(CompactionContent::Pending), - LanguageModelCompletionEvent::Compaction(CompactionContent::Encrypted { - id: Some("cmp_1".into()), - encrypted_content: "encrypted-blob".into(), - }), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::ProviderState( + provider_compaction_state_from_items(vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })]) + .unwrap() + ) + )), ] ); } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index bed470fff0d792..a4e37ac8ba7652 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -8,6 +8,11 @@ use serde_json::Value; use std::sync::Arc; use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; +use language_model_core::{ + CompactedContext, OPEN_AI_PROVIDER_ID, ProviderCompactionState, SharedString, +}; + +pub const COMPACTION_STATE_FORMAT: &str = "openai.responses.input-items.v1"; #[derive(Serialize, Debug)] pub struct Request { @@ -78,23 +83,54 @@ pub struct CompactedResponse { } impl CompactedResponse { - pub fn into_compaction_items(self) -> Result> { - if self.output.is_empty() { - return Err(anyhow!("OpenAI returned an empty compaction output")); - } - if !self.output.iter().any(|item| { - item.get("type") - .and_then(Value::as_str) - .is_some_and(|item_type| item_type == "compaction") - }) { - return Err(anyhow!( - "OpenAI compaction output did not contain a compaction item" - )); - } - Ok(self.output) + pub fn into_compacted_context(self) -> Result { + Ok(CompactedContext::ProviderState( + provider_compaction_state_from_items(self.output)?, + )) } } +pub fn provider_compaction_state_from_items(items: Vec) -> Result { + validate_compaction_items(&items)?; + Ok(ProviderCompactionState::new( + OPEN_AI_PROVIDER_ID, + SharedString::new_static(COMPACTION_STATE_FORMAT), + serde_json::to_string(&items)?, + )) +} + +pub fn provider_compaction_items(state: &ProviderCompactionState) -> Result>> { + if state.provider_id() != &OPEN_AI_PROVIDER_ID { + return Ok(None); + } + if state.format() != COMPACTION_STATE_FORMAT { + return Err(anyhow!( + "unsupported OpenAI compaction state format: {}", + state.format() + )); + } + + let items = serde_json::from_str::>(state.payload())?; + validate_compaction_items(&items)?; + Ok(Some(items)) +} + +fn validate_compaction_items(items: &[Value]) -> Result<()> { + if items.is_empty() { + return Err(anyhow!("OpenAI returned an empty compaction output")); + } + if !items.iter().any(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(|item_type| item_type == "compaction") + }) { + return Err(anyhow!( + "OpenAI compaction output did not contain a compaction item" + )); + } + Ok(()) +} + #[derive(Debug, Default)] pub struct ResponseInput { provider_items: Vec, @@ -925,12 +961,16 @@ mod tests { .unwrap(); assert_eq!( - response.into_compaction_items().unwrap(), - vec![json!({ + provider_compaction_items(&match response.into_compacted_context().unwrap() { + CompactedContext::ProviderState(state) => state, + CompactedContext::Summary { .. } => panic!("expected provider state"), + }) + .unwrap(), + Some(vec![json!({ "type": "compaction", "id": "cmp_manual", "encrypted_content": "opaque-state" - })] + })]) ); let (method, uri, authorization, body) = captured_request.lock().unwrap().take().unwrap(); assert_eq!(method, Method::POST); @@ -1040,7 +1080,11 @@ mod tests { })) .unwrap(); - assert_eq!(response.into_compaction_items().unwrap(), output); + let CompactedContext::ProviderState(state) = response.into_compacted_context().unwrap() + else { + panic!("expected provider state"); + }; + assert_eq!(provider_compaction_items(&state).unwrap(), Some(output)); } #[test] @@ -1066,7 +1110,7 @@ mod tests { assert!( response - .into_compaction_items() + .into_compacted_context() .unwrap_err() .to_string() .contains("compaction item") @@ -1092,13 +1136,56 @@ mod tests { assert!( response - .into_compaction_items() + .into_compacted_context() .unwrap_err() .to_string() .contains("empty") ); } + #[test] + fn provider_compaction_items_ignores_state_owned_by_another_provider() { + let items = vec![json!({ + "type": "compaction", + "id": "cmp_manual", + "encrypted_content": "opaque-state" + })]; + let mut state = provider_compaction_state_from_items(items).unwrap(); + + state = ProviderCompactionState::new( + language_model_core::LanguageModelProviderId::new("anthropic"), + state.format(), + state.payload(), + ); + assert_eq!(provider_compaction_items(&state).unwrap(), None); + } + + #[test] + fn provider_compaction_items_rejects_unknown_open_ai_format() { + let state = ProviderCompactionState::new( + OPEN_AI_PROVIDER_ID, + "openai.responses.input-items.v2", + "[]", + ); + assert!( + provider_compaction_items(&state) + .unwrap_err() + .to_string() + .contains("unsupported OpenAI compaction state format") + ); + } + + #[test] + fn provider_compaction_items_rejects_malformed_open_ai_state() { + let state = ProviderCompactionState::new( + OPEN_AI_PROVIDER_ID, + COMPACTION_STATE_FORMAT, + "not valid JSON", + ); + + assert!(provider_compaction_items(&state).is_err()); + } + fn compact_test_request() -> CompactRequest { CompactRequest { model: "gpt-5.4".to_string(), From 6da7b68da9f6b5bdbf82bed01c3498bf4cdb4193 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Jul 2026 08:30:09 -0600 Subject: [PATCH 04/12] Address review feedback - Document supersede semantics of provider-native compaction replay - Add context to malformed compaction payload errors and assert on it instead of serde_json's internal error text - Use the model's provider in the Cloud compaction request body instead of hardcoding OpenAI --- .../src/language_models_cloud.rs | 3 ++- crates/open_ai/src/completion.rs | 15 ++++++++++++++- crates/open_ai/src/responses.rs | 5 +++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index d798166e34e1ba..53888156992796 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -442,6 +442,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel {} MessageContent::Compaction(CompactedContext::ProviderState(state)) => { + // OpenAI's canonical replacement window already encodes all + // context retained at the compaction point, so everything + // accumulated before it -- earlier input items, earlier parts + // of this same message, and replay bookkeeping -- is + // superseded and must not be resent. When a transcript + // contains multiple compactions, each later window supersedes + // the previous one, so the last compaction wins. State owned + // by another provider yields `None`, in which case we fall + // back to replaying the full transcript. if let Some(items) = provider_compaction_items(&state)? { content_parts.clear(); input_items.clear(); @@ -4215,7 +4224,11 @@ mod tests { let error = into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap_err(); - assert!(error.to_string().contains("expected ident")); + assert!( + error + .to_string() + .contains("malformed OpenAI compaction state payload") + ); } #[test] diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index a4e37ac8ba7652..90e488641cb7fb 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, @@ -110,7 +110,8 @@ pub fn provider_compaction_items(state: &ProviderCompactionState) -> Result>(state.payload())?; + let items = serde_json::from_str::>(state.payload()) + .context("malformed OpenAI compaction state payload")?; validate_compaction_items(&items)?; Ok(Some(items)) } From 8a98df542fdd2bd4648396bf07c0bf49a0a6bfb2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Jul 2026 18:20:21 -0600 Subject: [PATCH 05/12] Scope provider-native compaction state to the producing backend Several backends share the OpenAI Responses request conversion and event mapper (OpenAI itself, Zed Cloud's OpenAI models, OpenAI-compatible endpoints, Codex, OpenCode, and Bedrock Mantle), but their encrypted compaction items are not interchangeable: only the backend whose infrastructure produced an item can decrypt it. Previously the shared converter accepted any state stamped with the OpenAI provider id and the shared mapper stamped every backend's compaction output with that id, so compacting with OpenAI and then switching to another Responses-protocol backend would discard the entire transcript in exchange for an opaque blob that backend cannot read. Thread the owning backend's provider id through into_open_ai_response, OpenAiResponseEventMapper, and the compaction state helpers so replay is limited to state the target backend produced; foreign state falls back to replaying the full transcript. Also surface malformed streams that end mid-compaction: Anthropic's mapper now errors when the message stops with an unclosed compaction block, and the OpenAI mapper errors when a response completes with a compaction item that was added but never finished, instead of leaving consumers with a Started event and no terminal signal. --- crates/anthropic/src/completion.rs | 49 +++ .../language_models/src/provider/bedrock.rs | 5 +- .../language_models/src/provider/open_ai.rs | 6 +- .../src/provider/open_ai_compatible.rs | 6 +- .../src/provider/openai_subscribed.rs | 3 +- .../language_models/src/provider/opencode.rs | 3 +- .../src/language_models_cloud.rs | 11 +- crates/open_ai/src/completion.rs | 382 +++++++++++++++--- crates/open_ai/src/responses.rs | 102 +++-- 9 files changed, 484 insertions(+), 83 deletions(-) diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index e1408ceb830f6f..fa25026089afc1 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -561,6 +561,17 @@ impl AnthropicEventMapper { ))] } Event::MessageStop => { + // Anthropic closes every content block before ending the + // message, so an unclosed compaction block means the stream + // was malformed and its finalized summary never arrived. + // Consumers would otherwise see `Started` with no terminal + // event and treat the compaction as still in progress. + if !self.compaction_summaries_by_index.is_empty() { + self.compaction_summaries_by_index.clear(); + return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "Anthropic ended the stream without finishing its compaction summary" + )))]; + } vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))] } Event::Error { error } => { @@ -1178,6 +1189,44 @@ mod tests { ); } + #[test] + fn test_event_mapper_rejects_stream_end_with_unfinished_compaction() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": "Summary " } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "message_stop" + })) + .unwrap(); + + let started = mapper + .map_event(start_event) + .into_iter() + .collect::, _>>() + .unwrap(); + assert_eq!( + started, + vec![ + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "Summary ".into() + )), + ] + ); + + let error = mapper.map_event(stop_event).pop().unwrap().unwrap_err(); + + assert!( + error + .to_string() + .contains("without finishing its compaction summary") + ); + } + #[test] fn test_usage_iterations_parsed_from_message_delta() { let event: Event = serde_json::from_value(serde_json::json!({ diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 955e2f29bfc50c..04481709cd0e2d 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1474,13 +1474,14 @@ impl LanguageModel for BedrockMantleModel { max_output_tokens, mantle_default_reasoning_effort(&self.model), self.model.supports_thinking(), + &PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; let completions = self.stream_response(request, cx); async move { - let mapper = OpenAiResponseEventMapper::new(); + let mapper = OpenAiResponseEventMapper::new(PROVIDER_ID); Ok(mapper.map_stream(completions.await?).boxed()) } .boxed() @@ -2696,6 +2697,7 @@ mod tests { Some(MantleModel::Grok4_3.max_output_tokens()), mantle_default_reasoning_effort(&MantleModel::Grok4_3), MantleModel::Grok4_3.supports_thinking(), + &PROVIDER_ID, ) .unwrap(); @@ -2748,6 +2750,7 @@ mod tests { Some(128_000), Some(ReasoningEffort::Medium), false, + &PROVIDER_ID, ) .unwrap(); diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 70c64d110a55ca..8367fe71bfedec 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -565,6 +565,7 @@ impl LanguageModel for OpenAiLanguageModel { self.model .supported_reasoning_efforts() .contains(&open_ai::ReasoningEffort::None), + &OPEN_AI_PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -574,7 +575,7 @@ impl LanguageModel for OpenAiLanguageModel { async move { let response = response.await?; response - .into_compacted_context() + .into_compacted_context(OPEN_AI_PROVIDER_ID) .map_err(LanguageModelCompletionError::Other) } .boxed() @@ -629,13 +630,14 @@ impl LanguageModel for OpenAiLanguageModel { self.model .supported_reasoning_efforts() .contains(&open_ai::ReasoningEffort::None), + &OPEN_AI_PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; let completions = self.stream_response(request, cx); async move { - let mapper = OpenAiResponseEventMapper::new(); + let mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); Ok(mapper.map_stream(completions.await?).boxed()) } .boxed() diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 1c93e69c18eac8..6e9a30e91b7f13 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -453,13 +453,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.max_output_tokens(), default_thinking_reasoning_effort(&self.model), supports_none_reasoning_effort(&self.model), + &self.provider_id, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; let completions = self.stream_response(request, cx); + let compaction_state_owner = self.provider_id.clone(); async move { - let mapper = OpenAiResponseEventMapper::new(); + let mapper = OpenAiResponseEventMapper::new(compaction_state_owner); Ok(mapper.map_stream(completions.await?).boxed()) } .boxed() @@ -638,6 +640,7 @@ mod tests { model.max_output_tokens, default_thinking_reasoning_effort(&model), supports_none_reasoning_effort(&model), + &LanguageModelProviderId::new("test-compatible-provider"), ) .unwrap(); let serialized = serde_json::to_value(request).unwrap(); @@ -668,6 +671,7 @@ mod tests { model.max_output_tokens, default_thinking_reasoning_effort(&model), supports_none_reasoning_effort(&model), + &LanguageModelProviderId::new("test-compatible-provider"), ) .unwrap(); let serialized = serde_json::to_value(request).unwrap(); diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 8fb936b976e1ef..7ccdacf1f021d9 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -529,6 +529,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { self.model .supported_reasoning_efforts() .contains(&ReasoningEffort::None), + &PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -597,7 +598,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { }); async move { - let mapper = OpenAiResponseEventMapper::new(); + let mapper = OpenAiResponseEventMapper::new(PROVIDER_ID); Ok(mapper.map_stream(future.await?.boxed()).boxed()) } .boxed() diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 5b868256f66124..47f9b95e5f7211 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -733,6 +733,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.max_output_tokens(self.subscription), None, supports_none_reasoning_effort, + &PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -740,7 +741,7 @@ impl LanguageModel for OpenCodeLanguageModel { let stream = self.stream_openai_response(response_request, http_client, extra_headers, cx); async move { - let mapper = OpenAiResponseEventMapper::new(); + let mapper = OpenAiResponseEventMapper::new(PROVIDER_ID); Ok(mapper.map_stream(stream.await?).boxed()) } .boxed() diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 53888156992796..589714596d5333 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -449,6 +449,9 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -498,7 +502,7 @@ impl LanguageModel for CloudLanguageModel { return response - .into_compacted_context() + .into_compacted_context(OPEN_AI_PROVIDER_ID) .map_err(LanguageModelCompletionError::Other); } CompletionEvent::Status(_) => {} @@ -685,6 +689,7 @@ impl LanguageModel for CloudLanguageModel request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -722,7 +727,7 @@ impl LanguageModel for CloudLanguageModel, default_reasoning_effort: Option, supports_none_reasoning_effort: bool, + compaction_state_owner: &LanguageModelProviderId, ) -> Result { let stream = !model_id.starts_with("o1-"); @@ -264,6 +270,7 @@ pub fn into_open_ai_response( append_message_to_response_items( message, index, + compaction_state_owner, &mut replayed_reasoning_item_indexes, &mut tool_use_kinds_by_id, &mut provider_items, @@ -367,6 +374,7 @@ pub fn into_open_ai_response( fn append_message_to_response_items( message: LanguageModelRequestMessage, index: usize, + compaction_state_owner: &LanguageModelProviderId, replayed_reasoning_item_indexes: &mut HashMap, tool_use_kinds_by_id: &mut HashMap, provider_items: &mut Vec, @@ -401,16 +409,16 @@ fn append_message_to_response_items( } MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) => {} MessageContent::Compaction(CompactedContext::ProviderState(state)) => { - // OpenAI's canonical replacement window already encodes all + // The canonical replacement window already encodes all // context retained at the compaction point, so everything // accumulated before it -- earlier input items, earlier parts // of this same message, and replay bookkeeping -- is // superseded and must not be resent. When a transcript // contains multiple compactions, each later window supersedes // the previous one, so the last compaction wins. State owned - // by another provider yields `None`, in which case we fall + // by another backend yields `None`, in which case we fall // back to replaying the full transcript. - if let Some(items) = provider_compaction_items(&state)? { + if let Some(items) = provider_compaction_items(&state, compaction_state_owner)? { content_parts.clear(); input_items.clear(); replayed_reasoning_item_indexes.clear(); @@ -839,11 +847,15 @@ struct RawToolCall { } pub struct OpenAiResponseEventMapper { + /// The backend whose infrastructure produced this stream; stamped on any + /// compaction state it emits so replay is limited to the same backend. + compaction_state_owner: LanguageModelProviderId, function_calls_by_item: HashMap, custom_tool_calls_by_item: HashMap, reasoning_items: Vec, current_message_phase: Option, pending_stop_reason: Option, + pending_compaction_items: usize, } #[derive(Default)] @@ -860,13 +872,15 @@ struct PendingResponseCustomToolCall { } impl OpenAiResponseEventMapper { - pub fn new() -> Self { + pub fn new(compaction_state_owner: LanguageModelProviderId) -> Self { Self { + compaction_state_owner, function_calls_by_item: HashMap::default(), custom_tool_calls_by_item: HashMap::default(), reasoning_items: Vec::new(), current_message_phase: None, pending_stop_reason: None, + pending_compaction_items: 0, } } @@ -935,6 +949,7 @@ impl OpenAiResponseEventMapper { } } ResponseOutputItem::Compaction(_) => { + self.pending_compaction_items += 1; events.push(Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::Started, ))); @@ -1118,10 +1133,15 @@ impl OpenAiResponseEventMapper { } } ResponseOutputItem::Compaction(compaction) => { + self.pending_compaction_items = self.pending_compaction_items.saturating_sub(1); match serde_json::to_value(ResponseInputItem::Compaction(compaction)) .map_err(anyhow::Error::from) - .and_then(|item| provider_compaction_state_from_items(vec![item])) - { + .and_then(|item| { + provider_compaction_state_from_items( + self.compaction_state_owner.clone(), + vec![item], + ) + }) { Ok(state) => vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::Finished(CompactedContext::ProviderState(state)), ))], @@ -1147,6 +1167,16 @@ impl OpenAiResponseEventMapper { response: ResponsesSummary, default_reason: StopReason, ) -> Vec> { + // A compaction item that was added but never done means the server + // already pruned its context, but we never received the canonical + // replacement window. Continuing as if the turn succeeded would + // leave the conversation unable to continue coherently. + if self.pending_compaction_items > 0 { + return vec![Err(LanguageModelCompletionError::Other(anyhow!( + "response completed with an unfinished compaction item" + )))]; + } + let mut events = Vec::new(); events.extend(self.capture_reasoning_items_from_output(&response.output)); @@ -1486,7 +1516,8 @@ mod tests { LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, Speed, + LanguageModelToolUseId, LanguageModelToolUseInput, OPEN_AI_PROVIDER_ID, SharedString, + Speed, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1498,7 +1529,7 @@ mod tests { fn map_response_events(events: Vec) -> Vec { block_on(async { - OpenAiResponseEventMapper::new() + OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID) .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) .collect::>() .await @@ -1835,6 +1866,7 @@ mod tests { Some(2048), Some(ReasoningEffort::Low), false, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2018,9 +2050,17 @@ mod tests { compact_at_tokens: None, }; - let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false) - .unwrap(); + let response = into_open_ai_response( + request, + "custom-model", + false, + false, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); let serialized = serde_json::to_value(response).unwrap(); assert_eq!( serialized, @@ -2119,6 +2159,7 @@ mod tests { None, Some(ReasoningEffort::Low), false, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2195,9 +2236,17 @@ mod tests { compact_at_tokens: None, }; - let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false) - .unwrap(); + let response = into_open_ai_response( + request, + "custom-model", + false, + false, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2261,6 +2310,7 @@ mod tests { None, Some(ReasoningEffort::Medium), false, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2298,8 +2348,17 @@ mod tests { compact_at_tokens: None, }; - let response = - into_open_ai_response(request, "gpt-5.4", true, true, None, None, true).unwrap(); + let response = into_open_ai_response( + request, + "gpt-5.4", + true, + true, + None, + None, + true, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); let serialized = serde_json::to_value(&response)?; assert_eq!( @@ -2433,6 +2492,7 @@ mod tests { None, Some(ReasoningEffort::Medium), true, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2473,6 +2533,7 @@ mod tests { None, Some(ReasoningEffort::Medium), true, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2525,6 +2586,7 @@ mod tests { None, Some(ReasoningEffort::Medium), false, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2616,6 +2678,7 @@ mod tests { None, Some(ReasoningEffort::Medium), false, + &OPEN_AI_PROVIDER_ID, ) .unwrap(); @@ -2697,9 +2760,17 @@ mod tests { compact_at_tokens: None, }; - let response = - into_open_ai_response(request, "custom-model", false, false, None, None, false) - .unwrap(); + let response = into_open_ai_response( + request, + "custom-model", + false, + false, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); let serialized = serde_json::to_value(&response).unwrap(); assert_eq!( @@ -2822,7 +2893,7 @@ mod tests { #[test] fn responses_stream_failed_uses_response_error_message() { - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(ResponsesStreamEvent::Failed { response: ResponseSummary { status: Some("failed".into()), @@ -2854,7 +2925,7 @@ mod tests { })) .expect("documented error event"); - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(event); assert_eq!(mapped.len(), 1); @@ -2879,7 +2950,7 @@ mod tests { })) .expect("nested error event"); - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(event); assert_eq!(mapped.len(), 1); @@ -2904,7 +2975,7 @@ mod tests { })) .expect("nested error event"); - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(event); assert_eq!(mapped.len(), 1); @@ -2917,7 +2988,7 @@ mod tests { #[test] fn responses_stream_maps_failed_context_length_exceeded_to_prompt_too_large() { - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(ResponsesStreamEvent::Failed { response: ResponseSummary { status: Some("failed".into()), @@ -2949,7 +3020,7 @@ mod tests { })) .expect("response error event"); - let mut mapper = OpenAiResponseEventMapper::new(); + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); let mapped = mapper.map_event(event); assert_eq!(mapped.len(), 1); @@ -4033,6 +4104,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::Compaction(CompactedContext::ProviderState( provider_compaction_state_from_items( + OPEN_AI_PROVIDER_ID, provider_input.as_array().unwrap().clone(), ) .unwrap(), @@ -4050,8 +4122,17 @@ mod tests { ..Default::default() }; - let response = - into_open_ai_response(request, "gpt-5.4", true, true, None, None, false).unwrap(); + let response = into_open_ai_response( + request, + "gpt-5.4", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["input"], @@ -4091,8 +4172,17 @@ mod tests { ..Default::default() }; - let mut response_request = - into_open_ai_response(request, "gpt-5.4", true, true, None, None, false).unwrap(); + let mut response_request = into_open_ai_response( + request, + "gpt-5.4", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); response_request.instructions = Some("Preserve implementation details.".to_string()); let compact_request = response_request.into_compact_request(); @@ -4128,8 +4218,17 @@ mod tests { ..Default::default() }; - let response = - into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); + let response = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["context_management"], @@ -4149,8 +4248,17 @@ mod tests { ..Default::default() }; - let response = - into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); + let response = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); assert!( serde_json::to_value(&response) @@ -4162,11 +4270,14 @@ mod tests { #[test] fn into_open_ai_response_replays_provider_compaction_block() { - let state = provider_compaction_state_from_items(vec![json!({ - "type": "compaction", - "id": "cmp_1", - "encrypted_content": "encrypted-blob" - })]) + let state = provider_compaction_state_from_items( + OPEN_AI_PROVIDER_ID, + vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })], + ) .unwrap(); let request = LanguageModelRequest { messages: vec![LanguageModelRequestMessage { @@ -4181,8 +4292,17 @@ mod tests { ..Default::default() }; - let response = - into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap(); + let response = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); assert_eq!( serde_json::to_value(&response).unwrap()["input"], @@ -4221,8 +4341,17 @@ mod tests { ..Default::default() }; - let error = - into_open_ai_response(request, "gpt-5.1", true, true, None, None, false).unwrap_err(); + let error = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap_err(); assert!( error @@ -4231,6 +4360,154 @@ mod tests { ); } + #[test] + fn into_open_ai_response_ignores_compaction_state_owned_by_another_backend() { + // Several backends share this request conversion (OpenAI itself, + // OpenAI-compatible endpoints, Codex, Mantle), but an encrypted + // compaction item is only decryptable by the backend that produced + // it. Replaying OpenAI-owned state through a different backend would + // discard the entire transcript in exchange for an opaque blob that + // backend cannot read, so the state must be ignored and the full + // transcript replayed instead. + let state = provider_compaction_state_from_items( + OPEN_AI_PROVIDER_ID, + vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })], + ) + .unwrap(); + let request = LanguageModelRequest { + messages: vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Set up the project.".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::Compaction(CompactedContext::ProviderState(state)), + MessageContent::Text("Done.".into()), + ], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Continue.".into())], + cache: false, + reasoning_details: None, + }, + ], + ..Default::default() + }; + + let response = into_open_ai_response( + request, + "compatible-model", + true, + true, + None, + None, + false, + &LanguageModelProviderId::new("my-compatible-endpoint"), + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(&response).unwrap()["input"], + json!([ + { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Set up the project." }] + }, + { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "Done.", "annotations": [] } + ] + }, + { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Continue." }] + } + ]) + ); + } + + #[test] + fn responses_stream_stamps_compaction_state_with_owning_backend() { + let owner = LanguageModelProviderId::new("my-compatible-endpoint"); + let mut mapper = OpenAiResponseEventMapper::new(owner.clone()); + let item: ResponseOutputItem = serde_json::from_value(json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })) + .unwrap(); + + let mut events = mapper.map_event(ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item, + }); + + let Some(Ok(LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::ProviderState(state), + )))) = events.pop() + else { + panic!("expected finished provider compaction state"); + }; + assert_eq!(state.provider_id(), &owner); + assert!( + crate::responses::provider_compaction_items(&state, &owner) + .unwrap() + .is_some() + ); + // OpenAI proper must not attempt to replay a window produced by a + // different backend's infrastructure. + assert_eq!( + crate::responses::provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(), + None + ); + } + + #[test] + fn responses_stream_rejects_completion_with_unfinished_compaction() { + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); + let item: ResponseOutputItem = serde_json::from_value(json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })) + .unwrap(); + + let started = mapper.map_event(ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item, + }); + assert!(matches!( + started.as_slice(), + [Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Started + ))] + )); + + let mut completed = mapper.map_event(ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }); + let error = completed.pop().unwrap().unwrap_err(); + + assert!(error.to_string().contains("unfinished compaction")); + } + #[test] fn responses_stream_maps_compaction_output_item() { let item: ResponseOutputItem = serde_json::from_value(json!({ @@ -4260,11 +4537,14 @@ mod tests { LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( CompactedContext::ProviderState( - provider_compaction_state_from_items(vec![json!({ - "type": "compaction", - "id": "cmp_1", - "encrypted_content": "encrypted-blob" - })]) + provider_compaction_state_from_items( + OPEN_AI_PROVIDER_ID, + vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })], + ) .unwrap() ) )), diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 90e488641cb7fb..dc2d6e8decb523 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; use language_model_core::{ - CompactedContext, OPEN_AI_PROVIDER_ID, ProviderCompactionState, SharedString, + CompactedContext, LanguageModelProviderId, ProviderCompactionState, SharedString, }; pub const COMPACTION_STATE_FORMAT: &str = "openai.responses.input-items.v1"; @@ -83,24 +83,46 @@ pub struct CompactedResponse { } impl CompactedResponse { - pub fn into_compacted_context(self) -> Result { + pub fn into_compacted_context( + self, + owner: LanguageModelProviderId, + ) -> Result { Ok(CompactedContext::ProviderState( - provider_compaction_state_from_items(self.output)?, + provider_compaction_state_from_items(owner, self.output)?, )) } } -pub fn provider_compaction_state_from_items(items: Vec) -> Result { +/// Packages a canonical replacement window into opaque provider state owned by +/// `owner`. +/// +/// Several backends speak the OpenAI Responses protocol (OpenAI itself, Zed +/// Cloud's OpenAI models, OpenAI-compatible endpoints, and others), but their +/// encrypted compaction items are not interchangeable: only the backend that +/// produced an item can decrypt it. The owner recorded here is what +/// [`provider_compaction_items`] later compares against, so it must identify +/// the backend whose infrastructure produced the items, not merely the wire +/// protocol. +pub fn provider_compaction_state_from_items( + owner: LanguageModelProviderId, + items: Vec, +) -> Result { validate_compaction_items(&items)?; Ok(ProviderCompactionState::new( - OPEN_AI_PROVIDER_ID, + owner, SharedString::new_static(COMPACTION_STATE_FORMAT), serde_json::to_string(&items)?, )) } -pub fn provider_compaction_items(state: &ProviderCompactionState) -> Result>> { - if state.provider_id() != &OPEN_AI_PROVIDER_ID { +/// Recovers the canonical replacement window from `state` if it is owned by +/// `owner`, or `None` when the state belongs to a different backend and the +/// caller should fall back to replaying the full transcript. +pub fn provider_compaction_items( + state: &ProviderCompactionState, + owner: &LanguageModelProviderId, +) -> Result>> { + if state.provider_id() != owner { return Ok(None); } if state.format() != COMPACTION_STATE_FORMAT { @@ -150,6 +172,13 @@ impl ResponseInput { self.provider_items.is_empty() && self.generated_items.is_empty() } + /// Filters only the items this crate generated from the request. + /// + /// Provider items are a canonical replacement window that must be replayed + /// verbatim, so they are exempt from filtering. Callers that rewrite the + /// input to satisfy backend-specific requirements (and therefore can't + /// tolerate arbitrary items inside a replayed window) should not accept + /// provider-native compaction state in the first place. pub fn retain(&mut self, predicate: impl FnMut(&ResponseInputItem) -> bool) { self.generated_items.retain(predicate); } @@ -905,6 +934,7 @@ mod tests { use super::*; use futures::executor::block_on; use http_client::FakeHttpClient; + use language_model_core::OPEN_AI_PROVIDER_ID; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -962,10 +992,16 @@ mod tests { .unwrap(); assert_eq!( - provider_compaction_items(&match response.into_compacted_context().unwrap() { - CompactedContext::ProviderState(state) => state, - CompactedContext::Summary { .. } => panic!("expected provider state"), - }) + provider_compaction_items( + &match response + .into_compacted_context(OPEN_AI_PROVIDER_ID) + .unwrap() + { + CompactedContext::ProviderState(state) => state, + CompactedContext::Summary { .. } => panic!("expected provider state"), + }, + &OPEN_AI_PROVIDER_ID + ) .unwrap(), Some(vec![json!({ "type": "compaction", @@ -1081,11 +1117,16 @@ mod tests { })) .unwrap(); - let CompactedContext::ProviderState(state) = response.into_compacted_context().unwrap() + let CompactedContext::ProviderState(state) = response + .into_compacted_context(OPEN_AI_PROVIDER_ID) + .unwrap() else { panic!("expected provider state"); }; - assert_eq!(provider_compaction_items(&state).unwrap(), Some(output)); + assert_eq!( + provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(), + Some(output) + ); } #[test] @@ -1111,7 +1152,7 @@ mod tests { assert!( response - .into_compacted_context() + .into_compacted_context(OPEN_AI_PROVIDER_ID) .unwrap_err() .to_string() .contains("compaction item") @@ -1137,7 +1178,7 @@ mod tests { assert!( response - .into_compacted_context() + .into_compacted_context(OPEN_AI_PROVIDER_ID) .unwrap_err() .to_string() .contains("empty") @@ -1151,14 +1192,29 @@ mod tests { "id": "cmp_manual", "encrypted_content": "opaque-state" })]; - let mut state = provider_compaction_state_from_items(items).unwrap(); + let state = + provider_compaction_state_from_items(OPEN_AI_PROVIDER_ID, items.clone()).unwrap(); + + assert_eq!( + provider_compaction_items(&state, &LanguageModelProviderId::new("anthropic")).unwrap(), + None + ); - state = ProviderCompactionState::new( - language_model_core::LanguageModelProviderId::new("anthropic"), - state.format(), - state.payload(), + // The same window stamped for a different OpenAI-protocol backend is + // opaque to OpenAI proper: encrypted compaction items are only + // decryptable by the infrastructure that produced them. + let compatible_backend = LanguageModelProviderId::new("my-compatible-endpoint"); + let state = + provider_compaction_state_from_items(compatible_backend.clone(), items).unwrap(); + assert_eq!( + provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(), + None + ); + assert!( + provider_compaction_items(&state, &compatible_backend) + .unwrap() + .is_some() ); - assert_eq!(provider_compaction_items(&state).unwrap(), None); } #[test] @@ -1169,7 +1225,7 @@ mod tests { "[]", ); assert!( - provider_compaction_items(&state) + provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID) .unwrap_err() .to_string() .contains("unsupported OpenAI compaction state format") @@ -1184,7 +1240,7 @@ mod tests { "not valid JSON", ); - assert!(provider_compaction_items(&state).is_err()); + assert!(provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).is_err()); } fn compact_test_request() -> CompactRequest { From b0201455ee6f7f598e27891464f63aec690a11a4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Jul 2026 18:54:08 -0600 Subject: [PATCH 06/12] Round-trip Anthropic compaction encrypted_content and map failed compactions Anthropic's compaction protocol attaches opaque encrypted_content to compaction blocks and requires it to be round-tripped verbatim. Deltas carry it as a whole-value replacement (like thinking signatures), so the mapper keeps the latest value rather than concatenating chunks. The finalized summary now carries it as owner-scoped provider state, and replay only includes it when the request targets the backend that produced it, mirroring how OpenAI Responses compaction state is scoped. A compaction block that closes without content is Anthropic's documented representation of a failed compaction (the server treats it as a no-op), so surface it as CompactionUpdate::Failed instead of erroring out the rest of the response. --- crates/anthropic/src/anthropic.rs | 16 +- crates/anthropic/src/completion.rs | 305 +++++++++++++++--- .../src/language_model_core.rs | 4 + crates/language_model_core/src/request.rs | 9 +- .../language_models/src/provider/anthropic.rs | 3 +- .../src/provider/anthropic_compatible.rs | 4 +- .../src/provider/copilot_chat.rs | 3 +- .../language_models/src/provider/opencode.rs | 3 +- .../src/language_models_cloud.rs | 7 +- 9 files changed, 309 insertions(+), 45 deletions(-) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 3531924f295e1a..3f034bf03c76ae 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -644,6 +644,10 @@ pub enum RequestContent { #[serde(rename = "compaction")] Compaction { content: Option>, + /// Opaque metadata from a prior compaction that must be round-tripped + /// verbatim for Anthropic to recognize the block. + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_content: Option>, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, @@ -679,7 +683,11 @@ pub enum ResponseContent { input: serde_json::Value, }, #[serde(rename = "compaction")] - Compaction { content: Option> }, + Compaction { + content: Option>, + #[serde(default)] + encrypted_content: Option>, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -932,7 +940,11 @@ pub enum ContentDelta { #[serde(rename = "input_json_delta")] InputJsonDelta { partial_json: String }, #[serde(rename = "compaction_delta")] - CompactionDelta { content: Option> }, + CompactionDelta { + content: Option>, + #[serde(default)] + encrypted_content: Option>, + }, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index fa25026089afc1..f02ab0b60487ae 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -1,15 +1,17 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactedContext, CompactionUpdate, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, + LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, LanguageModelToolUseInput, MessageContent, ProviderCompactionState, Role, + SharedString, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; use std::str::FromStr; +use std::sync::Arc; use crate::{ AdaptiveThinkingDisplay, AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, @@ -19,6 +21,46 @@ use crate::{ completion_error_from_anthropic_api, }; +pub const COMPACTION_STATE_FORMAT: &str = "anthropic.messages.encrypted-content.v1"; + +/// Packages a compaction block's opaque `encrypted_content` into provider +/// state owned by `owner`. +/// +/// Anthropic requires the metadata to be round-tripped verbatim, and only the +/// backend whose infrastructure produced it can make sense of it. The owner +/// recorded here is what [`provider_compaction_encrypted_content`] later +/// compares against, so it must identify that backend, not merely the wire +/// protocol. +pub fn provider_compaction_state_from_encrypted_content( + owner: LanguageModelProviderId, + encrypted_content: impl Into>, +) -> ProviderCompactionState { + ProviderCompactionState::new( + owner, + SharedString::new_static(COMPACTION_STATE_FORMAT), + encrypted_content, + ) +} + +/// Recovers the `encrypted_content` to round-trip from `state` if it is owned +/// by `owner`, or `None` when the state belongs to a different backend and the +/// summary should be replayed without it. +pub fn provider_compaction_encrypted_content( + state: &ProviderCompactionState, + owner: &LanguageModelProviderId, +) -> Result>> { + if state.provider_id() != owner { + return Ok(None); + } + if state.format() != COMPACTION_STATE_FORMAT { + return Err(anyhow!( + "unsupported Anthropic compaction state format: {}", + state.format() + )); + } + Ok(Some(state.payload().into())) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AnthropicPromptCacheMode { Disabled, @@ -68,7 +110,10 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca } } -fn to_anthropic_content(content: MessageContent) -> Result> { +fn to_anthropic_content( + content: MessageContent, + compaction_state_owner: &LanguageModelProviderId, +) -> Result> { match content { MessageContent::Text(text) => { let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { @@ -159,9 +204,19 @@ fn to_anthropic_content(content: MessageContent) -> Result { + MessageContent::Compaction(CompactedContext::Summary { + content, + provider_state, + }) => { + let encrypted_content = match &provider_state { + Some(state) => { + provider_compaction_encrypted_content(state, compaction_state_owner)? + } + None => None, + }; Ok(Some(RequestContent::Compaction { content: Some(content), + encrypted_content, cache_control: None, })) } @@ -176,6 +231,7 @@ pub fn into_anthropic( max_output_tokens: u64, mode: AnthropicModelMode, cache_mode: AnthropicPromptCacheMode, + compaction_state_owner: &LanguageModelProviderId, ) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); @@ -192,7 +248,7 @@ pub fn into_anthropic( Role::User | Role::Assistant => { let mut anthropic_message_content = Vec::new(); for content in message.content { - if let Some(content) = to_anthropic_content(content)? { + if let Some(content) = to_anthropic_content(content, compaction_state_owner)? { anthropic_message_content.push(content); } } @@ -354,20 +410,28 @@ pub fn into_anthropic( pub struct AnthropicEventMapper { tool_uses_by_index: HashMap, - compaction_summaries_by_index: HashMap, + compactions_by_index: HashMap, usage: Usage, stop_reason: StopReason, provider_name: LanguageModelProviderName, + compaction_state_owner: LanguageModelProviderId, } impl AnthropicEventMapper { - pub fn new(provider_name: LanguageModelProviderName) -> Self { + /// `compaction_state_owner` identifies the backend whose infrastructure + /// produced this stream, so that any `encrypted_content` it emits is only + /// ever round-tripped back to that same backend. + pub fn new( + provider_name: LanguageModelProviderName, + compaction_state_owner: LanguageModelProviderId, + ) -> Self { Self { tool_uses_by_index: HashMap::default(), - compaction_summaries_by_index: HashMap::default(), + compactions_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, provider_name, + compaction_state_owner, } } @@ -419,15 +483,23 @@ impl AnthropicEventMapper { ); Vec::new() } - ResponseContent::Compaction { content } => { + ResponseContent::Compaction { + content, + encrypted_content, + } => { let mut events = vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::Started, ))]; - let summary = self.compaction_summaries_by_index.entry(index).or_default(); + let compaction = self.compactions_by_index.entry(index).or_default(); + if let Some(encrypted_content) = + encrypted_content.filter(|encrypted| !encrypted.is_empty()) + { + compaction.encrypted_content = Some(encrypted_content); + } if let Some(content) = content && !content.is_empty() { - summary.push_str(&content); + compaction.summary.push_str(&content); events.push(Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::SummaryDelta(content), ))); @@ -451,16 +523,28 @@ impl AnthropicEventMapper { signature: Some(signature), })] } - ContentDelta::CompactionDelta { content } => { - let Some(content) = content.filter(|content| !content.is_empty()) else { - return Vec::new(); - }; - let Some(summary) = self.compaction_summaries_by_index.get_mut(&index) else { + ContentDelta::CompactionDelta { + content, + encrypted_content, + } => { + let Some(compaction) = self.compactions_by_index.get_mut(&index) else { return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( "Anthropic streamed a compaction delta before starting its content block" )))]; }; - summary.push_str(&content); + // Unlike summary text, `encrypted_content` arrives whole: + // a later delta carries a complete replacement value, not + // a chunk to append (Anthropic's own SDKs assign it, the + // way they do thinking signatures). + if let Some(encrypted_content) = + encrypted_content.filter(|encrypted| !encrypted.is_empty()) + { + compaction.encrypted_content = Some(encrypted_content); + } + let Some(content) = content.filter(|content| !content.is_empty()) else { + return Vec::new(); + }; + compaction.summary.push_str(&content); vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::SummaryDelta(content), ))] @@ -492,15 +576,26 @@ impl AnthropicEventMapper { } }, Event::ContentBlockStop { index } => { - if let Some(summary) = self.compaction_summaries_by_index.remove(&index) { - if summary.is_empty() { - return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( - "Anthropic returned an empty compaction summary" - )))]; + if let Some(compaction) = self.compactions_by_index.remove(&index) { + // A compaction block that closes without content is a + // documented failed compaction, which the server treats + // as a no-op: there is nothing to persist, and the + // conversation continues on the uncompacted transcript. + if compaction.summary.is_empty() { + return vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Failed, + ))]; } + let provider_state = compaction.encrypted_content.map(|encrypted_content| { + provider_compaction_state_from_encrypted_content( + self.compaction_state_owner.clone(), + encrypted_content, + ) + }); vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionUpdate::Finished(CompactedContext::Summary { - content: summary.into(), + content: compaction.summary.into(), + provider_state, }), ))] } else if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { @@ -566,8 +661,8 @@ impl AnthropicEventMapper { // was malformed and its finalized summary never arrived. // Consumers would otherwise see `Started` with no terminal // event and treat the compaction as still in progress. - if !self.compaction_summaries_by_index.is_empty() { - self.compaction_summaries_by_index.clear(); + if !self.compactions_by_index.is_empty() { + self.compactions_by_index.clear(); return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( "Anthropic ended the stream without finishing its compaction summary" )))]; @@ -591,6 +686,12 @@ struct RawToolUse { input_json: String, } +#[derive(Default)] +struct RawCompaction { + summary: String, + encrypted_content: Option>, +} + /// Updates usage data by preferring counts from `new`. fn update_usage(usage: &mut Usage, new: &Usage) { if let Some(input_tokens) = new.input_tokens { @@ -621,7 +722,8 @@ mod tests { use super::*; use crate::{AnthropicModelMode, UsageIteration, UsageIterationType}; use language_model_core::{ - ANTHROPIC_PROVIDER_NAME, LanguageModelImage, LanguageModelRequestMessage, MessageContent, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, LanguageModelImage, + LanguageModelRequestMessage, MessageContent, }; #[test] @@ -670,6 +772,7 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, ) .unwrap(); @@ -777,6 +880,7 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Legacy, + &ANTHROPIC_PROVIDER_ID, ) .unwrap(); @@ -836,6 +940,7 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, ) .unwrap(); @@ -889,6 +994,7 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, ) .unwrap(); @@ -935,6 +1041,7 @@ mod tests { budget_tokens: Some(10000), }, AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, ) .unwrap() } @@ -1035,6 +1142,7 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Disabled, + &ANTHROPIC_PROVIDER_ID, ) .unwrap(); @@ -1062,6 +1170,7 @@ mod tests { let result = request_with_assistant_content(vec![ MessageContent::Compaction(CompactedContext::Summary { content: "Summary of the conversation so far.".into(), + provider_state: None, }), MessageContent::Text("Response".to_string()), ]); @@ -1081,9 +1190,54 @@ mod tests { ); } + #[test] + fn test_compaction_encrypted_content_replayed_only_for_owning_backend() { + let summary_owned_by = |owner: LanguageModelProviderId| { + MessageContent::Compaction(CompactedContext::Summary { + content: "Summary of the conversation so far.".into(), + provider_state: Some(provider_compaction_state_from_encrypted_content( + owner, + "opaque-compaction-payload", + )), + }) + }; + + let owned = to_anthropic_content( + summary_owned_by(ANTHROPIC_PROVIDER_ID), + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap() + .expect("compaction block should be produced"); + assert_eq!( + serde_json::to_value(&owned).unwrap(), + serde_json::json!({ + "type": "compaction", + "content": "Summary of the conversation so far.", + "encrypted_content": "opaque-compaction-payload" + }) + ); + + // State produced by a different Anthropic-protocol backend must not + // be round-tripped: the summary is still replayed, but without the + // foreign encrypted payload. + let foreign = to_anthropic_content( + summary_owned_by(LanguageModelProviderId::new("other-anthropic-backend")), + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap() + .expect("compaction block should be produced"); + assert_eq!( + serde_json::to_value(&foreign).unwrap(), + serde_json::json!({ + "type": "compaction", + "content": "Summary of the conversation so far." + }) + ); + } + #[test] fn test_event_mapper_maps_compaction_block_and_deltas() { - let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); let start_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_start", @@ -1134,20 +1288,89 @@ mod tests { )), LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( CompactedContext::Summary { - content: "Summary in chunks".into() + content: "Summary in chunks".into(), + provider_state: None, } )), ] ); } + /// Mirrors the stream shape in Anthropic's SDK fixtures: the block starts + /// with both fields null, then a single delta carries the summary text + /// alongside the opaque `encrypted_content` that must be round-tripped. #[test] - fn test_event_mapper_rejects_empty_compaction_summary() { - let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + fn test_event_mapper_captures_encrypted_content_as_provider_state() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + let start_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_start", "index": 0, - "content_block": { "type": "compaction", "content": null } + "content_block": { "type": "compaction", "content": null, "encrypted_content": null } + })) + .unwrap(); + let delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "compaction_delta", + "content": "Earlier conversation summarized.", + "encrypted_content": "opaque-compaction-payload" + } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 + })) + .unwrap(); + + let mut events = Vec::new(); + events.extend(mapper.map_event(start_event)); + events.extend(mapper.map_event(delta_event)); + events.extend(mapper.map_event(stop_event)); + let mut events = events + .into_iter() + .collect::, _>>() + .expect("all events should map successfully"); + + let Some(LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::Summary { + content, + provider_state: Some(state), + }, + ))) = events.pop() + else { + panic!("expected a finished summary carrying provider state"); + }; + assert_eq!(content.as_ref(), "Earlier conversation summarized."); + assert_eq!( + provider_compaction_encrypted_content(&state, &ANTHROPIC_PROVIDER_ID) + .unwrap() + .as_deref(), + Some("opaque-compaction-payload") + ); + assert_eq!( + provider_compaction_encrypted_content( + &state, + &LanguageModelProviderId::new("other-anthropic-backend") + ) + .unwrap(), + None + ); + } + + /// A compaction block that closes without any content is Anthropic's + /// documented representation of a failed compaction, which the server + /// treats as a no-op. It must surface as `Failed` -- not as an error that + /// would kill the rest of the response. + #[test] + fn test_event_mapper_maps_null_content_compaction_to_failed() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": null, "encrypted_content": null } })) .unwrap(); let stop_event: Event = serde_json::from_value(serde_json::json!({ @@ -1166,13 +1389,21 @@ mod tests { CompactionUpdate::Started )] ); - let error = mapper.map_event(stop_event).pop().unwrap().unwrap_err(); - assert!(error.to_string().contains("empty compaction summary")); + assert_eq!( + mapper + .map_event(stop_event) + .into_iter() + .collect::, _>>() + .unwrap(), + vec![LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Failed + )] + ); } #[test] fn test_event_mapper_rejects_compaction_delta_before_start() { - let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); let delta_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_delta", "index": 0, @@ -1191,7 +1422,7 @@ mod tests { #[test] fn test_event_mapper_rejects_stream_end_with_unfinished_compaction() { - let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); let start_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_start", "index": 0, diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index eb84806a160a37..c5dfcc60c2a8ec 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -70,6 +70,10 @@ pub enum CompactionUpdate { SummaryDelta(Arc), /// The complete context to persist and use in subsequent requests. Finished(CompactedContext), + /// The provider abandoned the compaction without producing replacement + /// context. This is a documented outcome, not a protocol error: the + /// conversation simply continues on the uncompacted transcript. + Failed, } impl LanguageModelCompletionEvent { diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index b28c819b2efab3..68222eff967ed0 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -268,7 +268,14 @@ pub enum MessageContent { #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] pub enum CompactedContext { - Summary { content: Arc }, + Summary { + content: Arc, + /// Opaque state the producing backend needs round-tripped alongside + /// the summary (e.g. Anthropic's `encrypted_content`). `None` when the + /// summary stands alone. + #[serde(default)] + provider_state: Option, + }, ProviderState(ProviderCompactionState), } diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index a3df6cfe88624c..8edb4998ffdd3e 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -616,6 +616,7 @@ impl LanguageModel for AnthropicModel { self.model.max_output_tokens, self.model.mode.clone(), AnthropicPromptCacheMode::Automatic, + &PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -626,7 +627,7 @@ impl LanguageModel for AnthropicModel { let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { let response = request.await?; - Ok(AnthropicEventMapper::new(PROVIDER_NAME).map_stream(response)) + Ok(AnthropicEventMapper::new(PROVIDER_NAME, PROVIDER_ID).map_stream(response)) }); async move { Ok(future.await?.boxed()) }.boxed() } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 49813405000f69..c41045f2978a0d 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -345,6 +345,7 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { self.model.max_output_tokens, self.model.mode.clone(), self.cache_mode, + &self.provider_id, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -354,9 +355,10 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { } let completion_request = self.stream_completion(request, cx); let provider_name = self.provider_name.clone(); + let provider_id = self.provider_id.clone(); let future = self.request_limiter.stream(async move { let response = completion_request.await?; - Ok(AnthropicEventMapper::new(provider_name).map_stream(response)) + Ok(AnthropicEventMapper::new(provider_name, provider_id).map_stream(response)) }); async move { Ok(future.await?.boxed()) }.boxed() } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 4074fab35c2453..f5d16161ba3ca4 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -368,6 +368,7 @@ impl LanguageModel for CopilotChatLanguageModel { AnthropicModelMode::Default }, AnthropicPromptCacheMode::Legacy, + &PROVIDER_ID, )?; anthropic_request.temperature = None; @@ -413,7 +414,7 @@ impl LanguageModel for CopilotChatLanguageModel { request_limiter .stream(async move { let events = stream.await?; - let mapper = AnthropicEventMapper::new(PROVIDER_NAME); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME, PROVIDER_ID); Ok(mapper.map_stream(events).boxed()) }) .await diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 47f9b95e5f7211..2a4e27c3c7ce78 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -678,6 +678,7 @@ impl LanguageModel for OpenCodeLanguageModel { .unwrap_or(8192), mode, anthropic::completion::AnthropicPromptCacheMode::Automatic, + &PROVIDER_ID, ) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -685,7 +686,7 @@ impl LanguageModel for OpenCodeLanguageModel { let stream = self.stream_anthropic(anthropic_request, http_client, extra_headers, cx); async move { - let mapper = AnthropicEventMapper::new(PROVIDER_NAME); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME, PROVIDER_ID); Ok(mapper.map_stream(stream.await?).boxed()) } .boxed() diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 589714596d5333..1f97e050c1aedc 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -615,6 +615,10 @@ impl LanguageModel for CloudLanguageModel request, Err(error) => return async move { Err(error.into()) }.boxed(), @@ -658,7 +662,8 @@ impl LanguageModel for CloudLanguageModel Date: Wed, 22 Jul 2026 18:54:54 -0600 Subject: [PATCH 07/12] Report token usage from explicit compaction Compaction requests consume tokens like any other completion, but the usage OpenAI reports on /responses/compact was parsed and then dropped. LanguageModel::compact now returns a CompactionResult pairing the compacted context with that usage so callers can display what the compaction cost. --- crates/language_model/src/language_model.rs | 12 ++++++++- .../language_models/src/provider/open_ai.rs | 11 +++++--- .../src/language_models_cloud.rs | 25 +++++++++++++------ crates/open_ai/src/completion.rs | 2 +- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index fbf33c31256d69..5760d071d02ea4 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -35,6 +35,16 @@ impl DisabledReason { } } +/// The outcome of an explicit [`LanguageModel::compact`] request. +#[derive(Debug, Clone, PartialEq)] +pub struct CompactionResult { + /// The replacement context to persist and use in subsequent requests. + pub context: CompactedContext, + /// Token usage of the compaction request itself, as reported by the + /// provider. + pub usage: TokenUsage, +} + pub struct LanguageModelTextStream { pub message_id: Option, pub stream: BoxStream<'static, Result>, @@ -139,7 +149,7 @@ pub trait LanguageModel: Send + Sync { &self, _request: LanguageModelRequest, _cx: &AsyncApp, - ) -> BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { let provider = self.provider_name(); async move { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 8367fe71bfedec..9b59c56577c25c 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyConfiguration, ApiKeyState, AuthenticateError, CompactedContext, EnvVar, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, CompactionResult, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, @@ -25,6 +25,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::IconName; +use open_ai::completion::token_usage_from_response_usage; pub use open_ai::completion::{ ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, @@ -544,7 +545,7 @@ impl LanguageModel for OpenAiLanguageModel { &self, mut request: LanguageModelRequest, cx: &AsyncApp, - ) -> BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { if !self.supports_explicit_compaction() { return async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( @@ -574,9 +575,11 @@ impl LanguageModel for OpenAiLanguageModel { let response = self.compact_response(request, cx); async move { let response = response.await?; - response + let usage = token_usage_from_response_usage(&response.usage); + let context = response .into_compacted_context(OPEN_AI_PROVIDER_ID) - .map_err(LanguageModelCompletionError::Other) + .map_err(LanguageModelCompletionError::Other)?; + Ok(CompactionResult { context, usage }) } .boxed() } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 1f97e050c1aedc..695d4324890d49 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -19,7 +19,7 @@ use http_client::{ AsyncBody, HttpClient, HttpClientWithUrl, HttpRequestExt, Method, Response, StatusCode, }; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactedContext, DisabledReason, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactionResult, DisabledReason, GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, @@ -43,7 +43,7 @@ use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into use google_ai::completion::{GoogleEventMapper, into_google}; use open_ai::completion::{ ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, - into_open_ai_response, + into_open_ai_response, token_usage_from_response_usage, }; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; @@ -429,7 +429,7 @@ impl LanguageModel for CloudLanguageModel BoxFuture<'static, Result> { + ) -> BoxFuture<'static, Result> { if !self.supports_explicit_compaction() { return async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( @@ -501,9 +501,11 @@ impl LanguageModel for CloudLanguageModel { - return response + let usage = token_usage_from_response_usage(&response.usage); + let context = response .into_compacted_context(OPEN_AI_PROVIDER_ID) - .map_err(LanguageModelCompletionError::Other); + .map_err(LanguageModelCompletionError::Other)?; + return Ok(CompactionResult { context, usage }); } CompletionEvent::Status(_) => {} } @@ -1187,9 +1189,18 @@ mod tests { let model = cloud_test_model(http_client); let request = compact_test_request(); - let content = model.compact(request, &cx.to_async()).await.unwrap(); + let result = model.compact(request, &cx.to_async()).await.unwrap(); - let CompactedContext::ProviderState(state) = content else { + assert_eq!( + result.usage, + language_model::TokenUsage { + input_tokens: 80, + output_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 20, + } + ); + let language_model::CompactedContext::ProviderState(state) = result.context else { panic!("expected provider compaction state"); }; assert_eq!( diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 6d9f2a3b08a7ac..8267cab9a6db92 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -1464,7 +1464,7 @@ fn response_content_is_refusal(content: &serde_json::Value) -> bool { content_type == Some("refusal") || !refusal.is_empty() } -fn token_usage_from_response_usage(usage: &ResponsesUsage) -> TokenUsage { +pub fn token_usage_from_response_usage(usage: &ResponsesUsage) -> TokenUsage { let cache_read_input_tokens = usage.input_tokens_details.cached_tokens; TokenUsage { From 3cdff5a14991d7b8459cc8e3ab0e87d486dc2bc4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Jul 2026 19:20:29 -0600 Subject: [PATCH 08/12] Hoist system messages into instructions for the Responses API The Responses API documents the top-level instructions field as a system message inserted into the model's context on each request. Sending the system prompt there instead of as system-role input items fixes a hole in compaction replay: replaying provider compaction state replaces all accumulated input items with the canonical window, which previously discarded the current system prompt and left the model running on whatever prompt was frozen into the window at compaction time. This also unifies the Responses backends: the Codex backend rejects system-role input items and always required the instructions form, so its post-hoc extraction is no longer needed (it still always sends the field, as it always has). Compact requests inherit the field, so explicit compaction now runs with the current system prompt in context as well. NOTE: Zed Cloud's typed completion request currently has no instructions field and will silently drop it; cloud must deploy that field before this change ships to users. --- .../src/provider/openai_subscribed.rs | 22 +-- crates/open_ai/src/completion.rs | 170 +++++++++++++++++- 2 files changed, 167 insertions(+), 25 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 7ccdacf1f021d9..e67c9f3b65ba79 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -536,23 +536,11 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { }; responses_request.store = Some(false); - // The Codex backend requires system messages to be in the top-level - // `instructions` field rather than as input items. - let mut instructions = Vec::new(); - responses_request.input.retain(|item| { - if let open_ai::responses::ResponseInputItem::Message(msg) = item { - if msg.role == open_ai::Role::System { - for part in &msg.content { - if let open_ai::responses::ResponseInputContent::Text { text } = part { - instructions.push(text.clone()); - } - } - return false; - } - } - true - }); - responses_request.instructions = Some(instructions.join("\n\n")); + // `into_open_ai_response` already hoists system messages into + // `instructions`, which is the only form the Codex backend accepts. + // Codex has only ever been sent requests with the field present + // (possibly empty), so keep sending it even without system messages. + responses_request.instructions.get_or_insert_default(); let state = self.state.downgrade(); let http_client = self.http_client.clone(); diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 8267cab9a6db92..8b8da6e657edaf 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -266,7 +266,25 @@ pub fn into_open_ai_response( let mut input_items = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); let mut tool_use_kinds_by_id = HashMap::default(); + let mut system_instructions = Vec::new(); for (index, message) in messages.into_iter().enumerate() { + // System messages go to the top-level `instructions` field rather + // than the input item list. `instructions` is applied per request + // (the Responses API documents it as a system message inserted into + // the model's context), so the current system prompt survives when + // replaying provider compaction state replaces the accumulated input + // items below. This also matches the Codex backend, which rejects + // system-role input items outright. + if message.role == Role::System { + for content in message.content { + if let MessageContent::Text(text) = content + && !text.trim().is_empty() + { + system_instructions.push(text); + } + } + continue; + } append_message_to_response_items( message, index, @@ -340,7 +358,11 @@ pub fn into_open_ai_response( Ok(ResponseRequest { model: model_id.into(), - instructions: None, + instructions: if system_instructions.is_empty() { + None + } else { + Some(system_instructions.join("\n\n")) + }, input: crate::responses::ResponseInput::new(provider_items, input_items), store: Some(false), include, @@ -1873,14 +1895,8 @@ mod tests { let serialized = serde_json::to_value(&response).unwrap(); let expected = json!({ "model": "custom-model", + "instructions": "System context", "input": [ - { - "type": "message", - "role": "system", - "content": [ - { "type": "input_text", "text": "System context" } - ] - }, { "type": "message", "role": "user", @@ -4323,6 +4339,144 @@ mod tests { ); } + #[test] + fn into_open_ai_response_hoists_system_messages_into_instructions() { + let request = LanguageModelRequest { + messages: vec![ + LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("You are a coding assistant.".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello.".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("Prefer terse answers.".into())], + cache: false, + reasoning_details: None, + }, + ], + ..Default::default() + }; + + let response = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); + + let serialized = serde_json::to_value(&response).unwrap(); + assert_eq!( + serialized["instructions"], + json!("You are a coding assistant.\n\nPrefer terse answers.") + ); + assert_eq!( + serialized["input"], + json!([ + { + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "Hello." } + ] + } + ]) + ); + } + + /// Replaying provider compaction state discards all input items + /// accumulated before it, but the system prompt must not be lost with + /// them: it lives in the per-request `instructions` field, outside the + /// compacted window, so the model keeps running on the current prompt + /// rather than whatever was frozen into the window at compaction time. + #[test] + fn into_open_ai_response_preserves_system_prompt_across_compaction_replay() { + let state = provider_compaction_state_from_items( + OPEN_AI_PROVIDER_ID, + vec![json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + })], + ) + .unwrap(); + let request = LanguageModelRequest { + messages: vec![ + LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("Current system prompt.".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Old context.".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Compaction(CompactedContext::ProviderState( + state, + ))], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Continue.".into())], + cache: false, + reasoning_details: None, + }, + ], + ..Default::default() + }; + + let response = into_open_ai_response( + request, + "gpt-5.1", + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + ) + .unwrap(); + + let serialized = serde_json::to_value(&response).unwrap(); + assert_eq!(serialized["instructions"], json!("Current system prompt.")); + assert_eq!( + serialized["input"], + json!([ + { + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "encrypted-blob" + }, + { + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "Continue." } + ] + } + ]) + ); + } + #[test] fn into_open_ai_response_rejects_malformed_provider_compaction_state() { let request = LanguageModelRequest { From c4f1752399d287f7f7793215ed19395bab625d57 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Jul 2026 08:08:25 -0600 Subject: [PATCH 09/12] gpui: Allow accessibility activation in tests --- crates/gpui/src/platform/test/window.rs | 14 +++++++++++++- crates/gpui/src/window.rs | 9 +++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 8894350edc5edb..72c7b92b434cf0 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,5 +1,5 @@ use crate::{ - AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + A11yCallbacks, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, @@ -34,6 +34,7 @@ pub(crate) struct TestWindowState { resize_callback: Option, f32)>>, moved_callback: Option>, input_handler: Option, + a11y_callbacks: Option, is_fullscreen: bool, } @@ -86,10 +87,17 @@ impl TestWindow { resize_callback: None, moved_callback: None, input_handler: None, + a11y_callbacks: None, is_fullscreen: false, }))) } + pub fn simulate_a11y_activation(&self) { + if let Some(callbacks) = self.0.lock().a11y_callbacks.as_ref() { + drop((callbacks.activation)()); + } + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -292,6 +300,10 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} + fn a11y_init(&self, callbacks: A11yCallbacks) { + self.0.lock().a11y_callbacks = Some(callbacks); + } + fn draw(&self, scene: &Scene) { let scale_factor = self.scale_factor(); let mut state = self.0.lock(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 8667d605a343e6..d5c004777707b7 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2314,6 +2314,15 @@ impl Window { .render_to_image(&self.rendered_frame.scene) } + /// Simulates an assistive technology activating accessibility for this test window. + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_a11y_activation(&mut self) { + self.platform_window + .as_test() + .expect("test-support windows must use the test platform") + .simulate_a11y_activation(); + } + /// Set the content size of the window. pub fn resize(&mut self, size: Size) { self.platform_window.resize(size); From 57c096c47ade8a6b61e03248d288409e140c09c5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Jul 2026 08:46:30 -0600 Subject: [PATCH 10/12] gpui: Report duplicate accessibility node provenance --- crates/gpui/src/window/a11y.rs | 3 ++- crates/gpui/src/window/a11y/debug.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 234e7f0acd9fb8..8ea32773952023 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -408,9 +408,10 @@ impl A11yNodeBuilder { debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root"); if !self.seen_ids.insert(id) { + let existing_node = self.node_info.get(&id); debug_assert!( false, - "Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree." + "Duplicate a11y node id: {id:?}. The first node was {existing_node:?}. In a release build, this node would be silently discarded from the a11y tree." ); return false; } diff --git a/crates/gpui/src/window/a11y/debug.rs b/crates/gpui/src/window/a11y/debug.rs index ae98aa52837b11..3cec5b0ab8fe7e 100644 --- a/crates/gpui/src/window/a11y/debug.rs +++ b/crates/gpui/src/window/a11y/debug.rs @@ -28,7 +28,7 @@ struct CapturedFrame { } #[cfg(debug_assertions)] -#[derive(Clone, Default)] +#[derive(Clone, Debug, Default)] pub(crate) struct NodeDebugInfo { /// Whether the node was synthesized via /// [`crate::Element::a11y_synthetic_children`] rather than created from a From 78c9cdbef01fc704a7aada2e3f907bea649619cb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Jul 2026 08:54:16 -0600 Subject: [PATCH 11/12] gpui: Compile duplicate node diagnostics only in debug builds --- crates/gpui/src/window/a11y.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 8ea32773952023..e18a37c8cb9579 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -408,12 +408,17 @@ impl A11yNodeBuilder { debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root"); if !self.seen_ids.insert(id) { - let existing_node = self.node_info.get(&id); - debug_assert!( - false, - "Duplicate a11y node id: {id:?}. The first node was {existing_node:?}. In a release build, this node would be silently discarded from the a11y tree." - ); - return false; + #[cfg(debug_assertions)] + { + let existing_node = self.node_info.get(&id); + panic!( + "Duplicate a11y node id: {id:?}. The first node was {existing_node:?}. In a release build, this node would be silently discarded from the a11y tree." + ); + } + #[cfg(not(debug_assertions))] + { + return false; + } } true From 384aacbb16f9edb28456638fe79e0cad5fb5137f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 24 Jul 2026 08:59:45 -0600 Subject: [PATCH 12/12] Remove unrelated accessibility changes --- crates/gpui/src/platform/test/window.rs | 14 +------------- crates/gpui/src/window.rs | 9 --------- crates/gpui/src/window/a11y.rs | 16 +++++----------- crates/gpui/src/window/a11y/debug.rs | 2 +- 4 files changed, 7 insertions(+), 34 deletions(-) diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 72c7b92b434cf0..8894350edc5edb 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,5 +1,5 @@ use crate::{ - A11yCallbacks, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, @@ -34,7 +34,6 @@ pub(crate) struct TestWindowState { resize_callback: Option, f32)>>, moved_callback: Option>, input_handler: Option, - a11y_callbacks: Option, is_fullscreen: bool, } @@ -87,17 +86,10 @@ impl TestWindow { resize_callback: None, moved_callback: None, input_handler: None, - a11y_callbacks: None, is_fullscreen: false, }))) } - pub fn simulate_a11y_activation(&self) { - if let Some(callbacks) = self.0.lock().a11y_callbacks.as_ref() { - drop((callbacks.activation)()); - } - } - pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -300,10 +292,6 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn a11y_init(&self, callbacks: A11yCallbacks) { - self.0.lock().a11y_callbacks = Some(callbacks); - } - fn draw(&self, scene: &Scene) { let scale_factor = self.scale_factor(); let mut state = self.0.lock(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4e0b796634626a..312d797876bcfa 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2427,15 +2427,6 @@ impl Window { .render_to_image(&self.rendered_frame.scene) } - /// Simulates an assistive technology activating accessibility for this test window. - #[cfg(any(test, feature = "test-support"))] - pub fn simulate_a11y_activation(&mut self) { - self.platform_window - .as_test() - .expect("test-support windows must use the test platform") - .simulate_a11y_activation(); - } - /// Set the content size of the window. pub fn resize(&mut self, size: Size) { self.platform_window.resize(size); diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index e18a37c8cb9579..234e7f0acd9fb8 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -408,17 +408,11 @@ impl A11yNodeBuilder { debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root"); if !self.seen_ids.insert(id) { - #[cfg(debug_assertions)] - { - let existing_node = self.node_info.get(&id); - panic!( - "Duplicate a11y node id: {id:?}. The first node was {existing_node:?}. In a release build, this node would be silently discarded from the a11y tree." - ); - } - #[cfg(not(debug_assertions))] - { - return false; - } + debug_assert!( + false, + "Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree." + ); + return false; } true diff --git a/crates/gpui/src/window/a11y/debug.rs b/crates/gpui/src/window/a11y/debug.rs index 3cec5b0ab8fe7e..ae98aa52837b11 100644 --- a/crates/gpui/src/window/a11y/debug.rs +++ b/crates/gpui/src/window/a11y/debug.rs @@ -28,7 +28,7 @@ struct CapturedFrame { } #[cfg(debug_assertions)] -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub(crate) struct NodeDebugInfo { /// Whether the node was synthesized via /// [`crate::Element::a11y_synthetic_children`] rather than created from a