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.
This commit is contained in:
Conrad Irwin 2026-08-12 21:10:44 +00:00 committed by GitHub
parent ba0e2a9429
commit c05e34637b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 50 deletions

View file

@ -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)]

View file

@ -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<CompactionResult, LanguageModelCompletionError>> {
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::<Vec<_>>()
.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<dyn HttpClient> = 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<Option<(String, Vec<u8>)>>,
}