mirror of
https://github.com/zed-industries/zed.git
synced 2026-08-22 07:24:18 +00:00
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:
parent
ba0e2a9429
commit
c05e34637b
2 changed files with 187 additions and 50 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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>)>>,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue