From c05e34637b4f7f100a688bf6ac71cb70877fc8ad Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 12 Aug 2026 21:10:44 +0000 Subject: [PATCH] Fix ChatGPT subscription compaction (#62547) ChatGPT Subscription compaction currently uses the legacy `POST /responses/compact` endpoint. The Codex backend now returns `404 Not Found` from that route, which causes manual and threshold-triggered compaction to fail even though ordinary model requests continue to work. Use the current Codex compaction contracts over the normal streamed `POST /responses` endpoint instead. Automatic compaction now advertises server-side support and sends `context_management`, while manual compaction appends the transient `compaction_trigger` input item used by Codex's remote compaction v2 flow. Both paths consume the standard encrypted `compaction` output item, preserving backend-owned state for subsequent requests. The manual action remains available through `supports_explicit_compaction`; only its transport changes. The shared Responses input type gains the `compaction_trigger` wire item and an append operation for provider-specific request construction. Testing performed: - `cargo nextest run -p open_ai -p openai_subscribed --lib` - `./script/clippy -p open_ai -p openai_subscribed` - `cargo fmt -p open_ai -p openai_subscribed -- --check` - Verified with ChatGPT OAuth against `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, and `gpt-5.4-mini`: `compaction_trigger` returned HTTP 200 with a streamed `compaction` item for every model; forced `context_management` compaction also returned HTTP 200 for every model. The legacy `/responses/compact` route returned HTTP 404 for every model. Release Notes: - Fixed ChatGPT Subscription context compaction failing with an API endpoint error. --- crates/open_ai/src/responses.rs | 5 + .../src/openai_subscribed.rs | 232 ++++++++++++++---- 2 files changed, 187 insertions(+), 50 deletions(-) diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 68159c1720c..ac9c38c6d8d 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -197,6 +197,10 @@ impl ResponseInput { pub fn retain(&mut self, predicate: impl FnMut(&ResponseInputItem) -> bool) { self.generated_items.retain(predicate); } + + pub fn push(&mut self, item: ResponseInputItem) { + self.generated_items.push(item); + } } impl Serialize for ResponseInput { @@ -242,6 +246,7 @@ pub enum ResponseInputItem { CustomToolCallOutput(ResponseCustomToolCallOutputItem), Reasoning(ResponseReasoningInputItem), Compaction(ResponseCompactionItem), + CompactionTrigger, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/openai_subscribed/src/openai_subscribed.rs b/crates/openai_subscribed/src/openai_subscribed.rs index 164d5bd1265..1d29f32323f 100644 --- a/crates/openai_subscribed/src/openai_subscribed.rs +++ b/crates/openai_subscribed/src/openai_subscribed.rs @@ -15,7 +15,7 @@ use language_model::{ }; use open_ai::{ ReasoningEffort, - responses::{compact_response, stream_response}, + responses::{ResponseInputItem, stream_response}, }; use rand::RngCore as _; use serde::{Deserialize, Serialize}; @@ -25,9 +25,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use url::form_urlencoded; use util::ResultExt as _; -use open_ai::completion::{ - OpenAiResponseEventMapper, into_open_ai_response, token_usage_from_response_usage, -}; +use open_ai::completion::{OpenAiResponseEventMapper, into_open_ai_response}; pub const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openai-subscribed"); pub const PROVIDER_NAME: LanguageModelProviderName = @@ -466,6 +464,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { self.model.supports_priority() } + fn supports_server_side_compaction(&self) -> bool { + true + } + fn supports_explicit_compaction(&self) -> bool { true } @@ -475,10 +477,14 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { request: LanguageModelRequest, cx: &AsyncApp, ) -> BoxFuture<'static, Result> { - let compact_request = match self.codex_responses_request(request) { - Ok(responses_request) => responses_request.into_compact_request(), + let mut responses_request = match self.codex_responses_request(request) { + Ok(responses_request) => responses_request, Err(error) => return async move { Err(error.into()) }.boxed(), }; + responses_request.context_management = None; + responses_request + .input + .push(ResponseInputItem::CompactionTrigger); let state = self.state.downgrade(); let http_client = self.http_client.clone(); @@ -487,30 +493,50 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { cx.spawn(async move |cx| { let creds = get_fresh_credentials(&state, &http_client, cx).await?; let extra_headers = codex_extra_headers(&creds); - request_limiter - .run(async move { - let response = compact_response( + let access_token = creds.access_token.clone(); + let response_stream = request_limiter + .stream(async move { + stream_response( http_client.as_ref(), PROVIDER_NAME.0.as_str(), CODEX_BASE_URL, - &creds.access_token, - compact_request, + &access_token, + responses_request, &extra_headers, ) .await - .inspect_err(|error| { - log::error!( - "ChatGPT subscription compaction request to \ - {CODEX_BASE_URL}/responses/compact failed: {error}" - ); - })?; - let usage = token_usage_from_response_usage(&response.usage); - let context = response - .into_compacted_context(PROVIDER_ID) - .map_err(LanguageModelCompletionError::Other)?; - Ok(CompactionResult { context, usage }) + .map_err(LanguageModelCompletionError::from) }) - .await + .await?; + let mapper = OpenAiResponseEventMapper::new(PROVIDER_ID); + let mut event_stream = mapper.map_stream(response_stream.boxed()); + let mut compacted_context = None; + let mut usage = language_model::TokenUsage::default(); + + while let Some(event) = event_stream.next().await { + match event? { + LanguageModelCompletionEvent::Compaction( + language_model::CompactionUpdate::Finished(context), + ) => { + if compacted_context.replace(context).is_some() { + return Err(LanguageModelCompletionError::Other(anyhow!( + "ChatGPT subscription compaction returned multiple replacement contexts" + ))); + } + } + LanguageModelCompletionEvent::UsageUpdate(updated_usage) => { + usage = updated_usage; + } + _ => {} + } + } + + let context = compacted_context.ok_or_else(|| { + LanguageModelCompletionError::Other(anyhow!( + "ChatGPT subscription compaction returned no replacement context" + )) + })?; + Ok(CompactionResult { context, usage }) }) .boxed() } @@ -1314,16 +1340,16 @@ mod tests { } #[gpui::test] - async fn test_compact_posts_to_codex_compact_endpoint(cx: &mut TestAppContext) { - let compact_request_count = Arc::new(AtomicUsize::new(0)); + async fn test_server_side_compaction_streams_from_codex_responses(cx: &mut TestAppContext) { + let compaction_request_count = Arc::new(AtomicUsize::new(0)); let http_client = FakeHttpClient::create({ - let compact_request_count = compact_request_count.clone(); + let compaction_request_count = compaction_request_count.clone(); move |request| { - let compact_request_count = compact_request_count.clone(); + let compaction_request_count = compaction_request_count.clone(); async move { assert_eq!( request.uri().to_string(), - "https://chatgpt.com/backend-api/codex/responses/compact" + "https://chatgpt.com/backend-api/codex/responses" ); assert_eq!( request @@ -1339,21 +1365,24 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("account-123") ); - compact_request_count.fetch_add(1, Ordering::SeqCst); - // Only `output` is present, matching the least metadata a - // Responses-protocol backend is known to attach. - Ok(http_client::Response::builder().status(200).body( - http_client::AsyncBody::from( - serde_json::json!({ - "output": [{ - "type": "compaction", - "id": "cmp_1", - "encrypted_content": "opaque-state", - }], - }) - .to_string(), - ), - )?) + let mut request_body = String::new(); + smol::io::AsyncReadExt::read_to_string( + &mut request.into_body(), + &mut request_body, + ) + .await?; + let request_body: serde_json::Value = serde_json::from_str(&request_body)?; + assert_eq!( + request_body["context_management"], + serde_json::json!([{ + "type": "compaction", + "compact_threshold": 100_000, + }]) + ); + compaction_request_count.fetch_add(1, Ordering::SeqCst); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(compaction_response_stream()))?) } } }); @@ -1363,6 +1392,7 @@ mod tests { credentials.account_id = Some("account-123".to_string()); let state = make_state(http, Some(credentials), cx); let model = cx.read(|cx| create_language_model(ChatGptModel::Gpt55, &state, cx)); + assert!(model.supports_server_side_compaction()); assert!(model.supports_explicit_compaction()); let request = LanguageModelRequest { @@ -1372,19 +1402,31 @@ mod tests { cache: false, reasoning_details: None, }], + compact_at_tokens: Some(100_000), ..Default::default() }; let async_cx = cx.to_async(); - let result = model - .compact(request, &async_cx) + let events = model + .stream_completion(request, &async_cx) .await - .expect("compaction should succeed"); + .expect("the response stream should start") + .collect::>() + .await; - assert_eq!(compact_request_count.load(Ordering::SeqCst), 1); - assert_eq!(result.usage, language_model::TokenUsage::default()); - let language_model::CompactedContext::ProviderState(compaction_state) = result.context + assert_eq!(compaction_request_count.load(Ordering::SeqCst), 1); + assert!(matches!( + events.first(), + Some(Ok(LanguageModelCompletionEvent::Compaction( + language_model::CompactionUpdate::Started + ))) + )); + let Some(Ok(LanguageModelCompletionEvent::Compaction( + language_model::CompactionUpdate::Finished( + language_model::CompactedContext::ProviderState(compaction_state), + ), + ))) = events.get(1) else { - panic!("expected provider compaction state"); + panic!("expected the streamed provider compaction state"); }; assert_eq!(compaction_state.provider_id(), &PROVIDER_ID); let items = open_ai::responses::provider_compaction_items(&compaction_state, &PROVIDER_ID) @@ -1400,6 +1442,96 @@ mod tests { ); } + #[gpui::test] + async fn test_explicit_compaction_streams_with_codex_compaction_trigger( + cx: &mut TestAppContext, + ) { + let http_client = FakeHttpClient::create(move |request| async move { + assert_eq!( + request.uri().to_string(), + "https://chatgpt.com/backend-api/codex/responses" + ); + let mut request_body = String::new(); + smol::io::AsyncReadExt::read_to_string(&mut request.into_body(), &mut request_body) + .await?; + let request_body: serde_json::Value = serde_json::from_str(&request_body)?; + assert!(request_body.get("context_management").is_none()); + assert_eq!( + request_body["input"] + .as_array() + .and_then(|input| input.last()), + Some(&serde_json::json!({"type": "compaction_trigger"})) + ); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(compaction_response_stream()))?) + }); + + let http: Arc = http_client; + let state = make_state(http, Some(make_fresh_credentials()), cx); + let model = cx.read(|cx| create_language_model(ChatGptModel::Gpt55, &state, cx)); + let request = LanguageModelRequest { + messages: vec![language_model::LanguageModelRequestMessage { + role: language_model::Role::User, + content: vec![language_model::MessageContent::Text("Hello".into())], + cache: false, + reasoning_details: None, + }], + compact_at_tokens: Some(100_000), + ..Default::default() + }; + + let result = model + .compact(request, &cx.to_async()) + .await + .expect("manual compaction should succeed"); + let language_model::CompactedContext::ProviderState(compaction_state) = result.context + else { + panic!("expected provider compaction state"); + }; + let items = open_ai::responses::provider_compaction_items(&compaction_state, &PROVIDER_ID) + .expect("the compacted state should parse") + .expect("the compacted state should be owned by the subscription provider"); + assert_eq!( + items, + vec![serde_json::json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "opaque-state", + })] + ); + } + + fn compaction_response_stream() -> String { + let compaction_item = serde_json::json!({ + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "opaque-state", + }); + [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": compaction_item, + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": compaction_item, + }), + serde_json::json!({ + "type": "response.completed", + "response": { + "status": "completed", + "output": [], + }, + }), + ] + .into_iter() + .map(|event| format!("data: {event}\n\n")) + .collect() + } + struct FakeCredentialsProvider { storage: Mutex)>>, }