Skip to content

Commit ee3b555

Browse files
authored
Classify OpenAI subscription usage limits correctly (#63738)
OpenAI's Codex endpoint reports an exhausted ChatGPT subscription with HTTP 429 and `error.type = "usage_limit_reached"`, but no `error.code`. The Responses parser discarded the type and classified the response from its status alone, making it appear to be transient request throttling. This preserves `error.type` in both Responses and Chat Completions parsing, considers both OpenAI discriminator fields during classification, and falls back from the more-specific `error.code` to `error.type` when populating the existing provider rejection code. Usage-limit errors retain OpenAI's message and are not retried as transient 429s. The regression coverage exercises initial HTTP failures, Responses stream errors, Chat Completions stream errors, unknown OpenAI-compatible provider types, and precedence when both code and type are present. Release Notes: - Fixed ChatGPT subscription usage limits being shown as temporary OpenAI request throttling.
1 parent 5313741 commit ee3b555

6 files changed

Lines changed: 268 additions & 59 deletions

File tree

crates/language_model_core/src/chat_completion.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ where
7474
/// `{"error": {...}}` envelope.
7575
#[derive(Serialize, Deserialize, Debug)]
7676
pub struct ResponseStreamError {
77+
#[serde(default)]
78+
pub code: Option<String>,
79+
#[serde(default, rename = "type")]
80+
pub error_type: Option<String>,
7781
pub message: String,
7882
}
7983

crates/language_model_core/src/language_model_core.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -379,15 +379,16 @@ impl LanguageModelCompletionError {
379379
category,
380380
..
381381
} => {
382-
status.is_some_and(|status| is_retryable_provider_status(provider, status))
383-
|| matches!(
384-
category,
385-
ProviderErrorCategory::RateLimit
386-
| ProviderErrorCategory::Overloaded
387-
| ProviderErrorCategory::Timeout
388-
| ProviderErrorCategory::InternalServer
389-
)
390-
|| retry_after.is_some()
382+
*category != ProviderErrorCategory::PaymentRequired
383+
&& (status.is_some_and(|status| is_retryable_provider_status(provider, status))
384+
|| matches!(
385+
category,
386+
ProviderErrorCategory::RateLimit
387+
| ProviderErrorCategory::Overloaded
388+
| ProviderErrorCategory::Timeout
389+
| ProviderErrorCategory::InternalServer
390+
)
391+
|| retry_after.is_some())
391392
}
392393
Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => true,
393394
Self::DataRetentionConsentRequired { .. }

crates/open_ai/src/chat_completion_transport_tests.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use http_client::{
44
FakeHttpClient, Response,
55
http::{HeaderName, HeaderValue},
66
};
7+
use language_model_core::{
8+
LanguageModelCompletionError, LanguageModelProviderName, ProviderErrorCategory,
9+
};
710
use serde_json::json;
811
use std::sync::{Arc, Mutex};
912

@@ -115,6 +118,59 @@ fn streaming_transport_does_not_synthesize_done_at_eof() {
115118
}
116119
}
117120

121+
#[test]
122+
fn streaming_transport_preserves_usage_limit_errors() {
123+
let client = FakeHttpClient::create(|_| async move {
124+
Ok(Response::builder()
125+
.status(200)
126+
.body(AsyncBody::from(concat!(
127+
"data: {\"error\":{\"type\":\"usage_limit_reached\",",
128+
"\"message\":\"The usage limit has been reached\"}}\n\n"
129+
)))?)
130+
});
131+
let request = serde_json::from_value(json!({
132+
"model": "custom/model",
133+
"messages": [],
134+
"stream": true
135+
}))
136+
.expect("valid chat completion request");
137+
138+
let error = block_on(async {
139+
stream_completion(
140+
client.as_ref(),
141+
"Compatible Provider",
142+
"https://example.com/v1",
143+
"secret",
144+
request,
145+
&CustomHeaders::default(),
146+
)
147+
.await
148+
.expect("streaming request")
149+
.next()
150+
.await
151+
.expect("provider error event")
152+
.expect_err("usage limit should reject the completion")
153+
});
154+
let error = error
155+
.downcast_ref::<LanguageModelCompletionError>()
156+
.expect("typed provider rejection");
157+
158+
assert!(!error.is_transient());
159+
assert!(matches!(
160+
error,
161+
LanguageModelCompletionError::ProviderRejection {
162+
provider,
163+
status: None,
164+
code: Some(code),
165+
message,
166+
category: ProviderErrorCategory::PaymentRequired,
167+
..
168+
} if provider == &LanguageModelProviderName::from("Compatible Provider".to_string())
169+
&& code == "usage_limit_reached"
170+
&& message == "The usage limit has been reached"
171+
));
172+
}
173+
118174
#[test]
119175
fn transport_preserves_typed_send_and_deserialization_errors() {
120176
let client = FakeHttpClient::create(|_| async move { Err(anyhow!("network unavailable")) });

crates/open_ai/src/completion.rs

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,11 +1356,16 @@ fn completion_error_from_response_error(
13561356
error: &ResponseError,
13571357
provider: language_model_core::LanguageModelProviderName,
13581358
) -> LanguageModelCompletionError {
1359-
let category = response_error_category(error.code.as_deref(), None, &error.message);
1359+
let category = response_error_category(
1360+
error.code.as_deref(),
1361+
error.error_type.as_deref(),
1362+
None,
1363+
&error.message,
1364+
);
13601365
LanguageModelCompletionError::from_provider_response(
13611366
provider,
13621367
None,
1363-
error.code.clone(),
1368+
error.code.clone().or_else(|| error.error_type.clone()),
13641369
error.message.clone(),
13651370
None,
13661371
category,
@@ -1369,41 +1374,52 @@ fn completion_error_from_response_error(
13691374

13701375
pub(crate) fn response_error_category(
13711376
code: Option<&str>,
1377+
error_type: Option<&str>,
13721378
status: Option<StatusCode>,
13731379
message: &str,
13741380
) -> ProviderErrorCategory {
1375-
match code {
1376-
Some("context_length_exceeded" | "request_too_large") => {
1381+
code.and_then(response_error_category_from_discriminator)
1382+
.or_else(|| error_type.and_then(response_error_category_from_discriminator))
1383+
.unwrap_or_else(|| {
1384+
status
1385+
.map(|status| ProviderErrorCategory::from_http_status(status, message))
1386+
.unwrap_or(ProviderErrorCategory::Other)
1387+
})
1388+
}
1389+
1390+
fn response_error_category_from_discriminator(
1391+
discriminator: &str,
1392+
) -> Option<ProviderErrorCategory> {
1393+
let category = match discriminator {
1394+
"context_length_exceeded" | "request_too_large" => {
13771395
ProviderErrorCategory::PromptTooLarge { tokens: None }
13781396
}
1379-
Some("invalid_encrypted_content") => ProviderErrorCategory::InvalidEncryptedContent,
1380-
Some("invalid_request_error") => ProviderErrorCategory::InvalidRequest,
1381-
Some("authentication_error") => ProviderErrorCategory::Authentication,
1382-
Some(
1383-
"billing_error"
1384-
| "payment_required_error"
1385-
| "credit_balance_exhausted"
1386-
| "insufficient_quota"
1387-
| "organization_spend_limit_exceeded"
1388-
| "project_spend_limit_exceeded"
1389-
| "organization_usage_limit_exceeded",
1390-
) => ProviderErrorCategory::PaymentRequired,
1391-
Some("permission_error") => ProviderErrorCategory::Permission,
1392-
Some("cyber_policy" | "invalid_prompt") => ProviderErrorCategory::ContentPolicy,
1393-
Some("not_found_error") => ProviderErrorCategory::EndpointNotFound,
1394-
Some("conflict_error") => ProviderErrorCategory::Conflict,
1395-
Some("rate_limit_error" | "rate_limit_exceeded") => ProviderErrorCategory::RateLimit,
1396-
Some("timeout_error" | "request_timed_out") => ProviderErrorCategory::Timeout,
1397-
Some("api_error" | "internal_server_error" | "server_error") => {
1397+
"invalid_encrypted_content" => ProviderErrorCategory::InvalidEncryptedContent,
1398+
"invalid_request_error" => ProviderErrorCategory::InvalidRequest,
1399+
"authentication_error" => ProviderErrorCategory::Authentication,
1400+
"billing_error"
1401+
| "payment_required_error"
1402+
| "credit_balance_exhausted"
1403+
| "insufficient_quota"
1404+
| "organization_spend_limit_exceeded"
1405+
| "project_spend_limit_exceeded"
1406+
| "organization_usage_limit_exceeded"
1407+
| "usage_limit_reached" => ProviderErrorCategory::PaymentRequired,
1408+
"permission_error" => ProviderErrorCategory::Permission,
1409+
"cyber_policy" | "invalid_prompt" => ProviderErrorCategory::ContentPolicy,
1410+
"not_found_error" => ProviderErrorCategory::EndpointNotFound,
1411+
"conflict_error" => ProviderErrorCategory::Conflict,
1412+
"rate_limit_error" | "rate_limit_exceeded" => ProviderErrorCategory::RateLimit,
1413+
"timeout_error" | "request_timed_out" => ProviderErrorCategory::Timeout,
1414+
"api_error" | "internal_server_error" | "server_error" => {
13981415
ProviderErrorCategory::InternalServer
13991416
}
1400-
Some("overloaded_error" | "server_is_overloaded" | "slow_down") => {
1417+
"overloaded_error" | "server_is_overloaded" | "slow_down" => {
14011418
ProviderErrorCategory::Overloaded
14021419
}
1403-
Some(_) | None => status
1404-
.map(|status| ProviderErrorCategory::from_http_status(status, message))
1405-
.unwrap_or(ProviderErrorCategory::Other),
1406-
}
1420+
_ => return None,
1421+
};
1422+
Some(category)
14071423
}
14081424

14091425
fn response_error_message(error: &ResponseError) -> String {
@@ -2855,6 +2871,7 @@ mod tests {
28552871
status: Some("failed".into()),
28562872
error: Some(ResponseError {
28572873
code: Some("server_error".into()),
2874+
error_type: None,
28582875
message: "The model failed to generate a response.".into(),
28592876
param: None,
28602877
}),
@@ -2947,6 +2964,35 @@ mod tests {
29472964
));
29482965
}
29492966

2967+
#[test]
2968+
fn responses_stream_preserves_and_classifies_type_without_code() {
2969+
let event = serde_json::from_value::<ResponsesStreamEvent>(json!({
2970+
"type": "error",
2971+
"error": {
2972+
"type": "usage_limit_reached",
2973+
"message": "The usage limit has been reached",
2974+
"plan_type": "plus"
2975+
}
2976+
}))
2977+
.expect("nested usage limit error event");
2978+
2979+
let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID);
2980+
let mapped = mapper.map_event(event);
2981+
2982+
assert_eq!(mapped.len(), 1);
2983+
let error = mapped.into_iter().next().unwrap().unwrap_err();
2984+
assert!(matches!(
2985+
error,
2986+
LanguageModelCompletionError::ProviderRejection {
2987+
code: Some(code),
2988+
message,
2989+
category: ProviderErrorCategory::PaymentRequired,
2990+
..
2991+
} if code == "usage_limit_reached"
2992+
&& message == "The usage limit has been reached"
2993+
));
2994+
}
2995+
29502996
#[test]
29512997
fn responses_stream_maps_billing_codes_to_payment_required() {
29522998
for code in [
@@ -2959,17 +3005,30 @@ mod tests {
29593005
"organization_usage_limit_exceeded",
29603006
] {
29613007
assert_eq!(
2962-
response_error_category(Some(code), None, ""),
3008+
response_error_category(Some(code), None, None, ""),
29633009
ProviderErrorCategory::PaymentRequired,
29643010
"{code}"
29653011
);
29663012
}
29673013
}
29683014

3015+
#[test]
3016+
fn responses_stream_uses_known_type_when_code_is_unknown() {
3017+
assert_eq!(
3018+
response_error_category(
3019+
Some("provider_specific_code"),
3020+
Some("usage_limit_reached"),
3021+
Some(StatusCode::TOO_MANY_REQUESTS),
3022+
"",
3023+
),
3024+
ProviderErrorCategory::PaymentRequired
3025+
);
3026+
}
3027+
29693028
#[test]
29703029
fn responses_stream_maps_invalid_prompt_to_content_policy() {
29713030
assert_eq!(
2972-
response_error_category(Some("invalid_prompt"), None, ""),
3031+
response_error_category(Some("invalid_prompt"), None, None, ""),
29733032
ProviderErrorCategory::ContentPolicy
29743033
);
29753034
}
@@ -2978,7 +3037,7 @@ mod tests {
29783037
fn responses_stream_maps_overload_codes_to_overloaded() {
29793038
for code in ["overloaded_error", "server_is_overloaded", "slow_down"] {
29803039
assert_eq!(
2981-
response_error_category(Some(code), None, ""),
3040+
response_error_category(Some(code), None, None, ""),
29823041
ProviderErrorCategory::Overloaded,
29833042
"{code}"
29843043
);
@@ -3021,6 +3080,7 @@ mod tests {
30213080
status: Some("failed".into()),
30223081
error: Some(ResponseError {
30233082
code: Some("context_length_exceeded".into()),
3083+
error_type: None,
30243084
message: "Your input exceeds the context window of this model.".into(),
30253085
param: Some("input".into()),
30263086
}),

0 commit comments

Comments
 (0)