diff --git a/crates/jp_llm/src/provider/openrouter.rs b/crates/jp_llm/src/provider/openrouter.rs index 3b816fc84..ec287f47d 100644 --- a/crates/jp_llm/src/provider/openrouter.rs +++ b/crates/jp_llm/src/provider/openrouter.rs @@ -498,12 +498,27 @@ fn map_completion( "Received event from OpenRouter API." ); + // A failure after the response is committed arrives as a top-level `error` + // on an otherwise ordinary chunk. The accompanying `choices` array may be + // empty, in which case this is the only place the failure is reported. + if let Some(error) = v.error { + return vec![Err(map_error(error))]; + } + v.choices .into_iter() .flat_map(|v| map_event(v, state)) .collect() } +/// Classify an OpenRouter error payload by its status code. +fn map_error(error: response::ErrorResponse) -> StreamError { + StreamError::from(jp_openrouter::Error::Api { + code: error.code, + message: error.message, + }) +} + #[expect(clippy::too_many_lines)] fn map_event( choice: types::response::Choice, @@ -539,17 +554,7 @@ fn map_event( let reasoning_details = MultiProviderMetadata::from_details(reasoning_details); if let Some(error) = error { - if looks_like_quota_error(&error.message) { - return vec![Err(StreamError::new( - StreamErrorKind::InsufficientQuota, - format!( - "Insufficient API quota. Check your credits \ - at https://openrouter.ai/settings/credits. ({})", - error.message - ), - ))]; - } - return vec![Err(StreamError::other(error.message))]; + return vec![Err(map_error(error))]; } let mut events = vec![]; diff --git a/crates/jp_llm/src/provider/openrouter_tests.rs b/crates/jp_llm/src/provider/openrouter_tests.rs index 20d89ac37..1c20d2fa2 100644 --- a/crates/jp_llm/src/provider/openrouter_tests.rs +++ b/crates/jp_llm/src/provider/openrouter_tests.rs @@ -196,6 +196,108 @@ fn tool_call_finish_is_a_clean_completion() -> Result { Ok(()) } +/// A prompt rejected after the response is committed arrives as a 200 stream +/// whose only chunk carries a top-level `error` and an empty `choices` array. +/// The rejection must reach the caller instead of being dropped, which would +/// leave the stream looking merely truncated. +#[test] +fn top_level_error_with_empty_choices_is_reported() -> Result { + let chunk: OpenRouterChunk = serde_json::from_value(serde_json::json!({ + "id": "gen-1786537769-1XuUJY00pSj6s5GLfq9d", + "provider": "Azure", + "choices": [], + "created": 1_786_537_769, + "model": "unknown", + "object": "chat.completion.chunk", + "system_fingerprint": null, + "usage": null, + "error": { + "code": 400, + "message": "prompt is too long: 1284668 tokens > 1000000 maximum", + "metadata": null + } + }))?; + let mut state = AggregationState { + tool_call_indices: vec![], + aggregating_reasoning: false, + aggregating_message: false, + is_structured: false, + }; + + let events = map_completion(chunk, &mut state); + + let [Err(error)] = events.as_slice() else { + panic!("expected exactly one error event, got {events:?}"); + }; + assert_eq!( + error.message(), + "API error (status 400): prompt is too long: 1284668 tokens > 1000000 maximum" + ); + // A 400 is the provider rejecting this request as-is. Retrying resends the + // identical body, so it must not be classified as transient. + assert_eq!(error.kind, StreamErrorKind::Other); + assert!(!error.is_retryable()); + Ok(()) +} + +/// A quota failure delivered as a top-level error is classified by its status +/// code, so the caller can surface the credits hint rather than a bare message. +#[test] +fn top_level_payment_error_is_classified_as_quota() -> Result { + let chunk: OpenRouterChunk = serde_json::from_value(serde_json::json!({ + "id": "gen-1", + "provider": "Azure", + "choices": [], + "created": 1_786_537_769, + "model": "unknown", + "object": "chat.completion.chunk", + "system_fingerprint": null, + "usage": null, + "error": { "code": 402, "message": "Payment required", "metadata": null } + }))?; + let mut state = AggregationState { + tool_call_indices: vec![], + aggregating_reasoning: false, + aggregating_message: false, + is_structured: false, + }; + + let events = map_completion(chunk, &mut state); + + let [Err(error)] = events.as_slice() else { + panic!("expected exactly one error event, got {events:?}"); + }; + assert_eq!(error.kind, StreamErrorKind::InsufficientQuota); + Ok(()) +} + +/// A per-choice error is classified by status code on the same path as a +/// top-level one: a 503 from the upstream provider is worth retrying. +#[test] +fn per_choice_error_is_classified_by_status_code() -> Result { + let choice: response::Choice = serde_json::from_value(serde_json::json!({ + "finish_reason": "error", + "native_finish_reason": "error", + "delta": { "role": null, "content": "", "reasoning": null, "tool_calls": [] }, + "error": { "code": 503, "message": "Provider disconnected unexpectedly", "metadata": null } + }))?; + let mut state = AggregationState { + tool_call_indices: vec![], + aggregating_reasoning: false, + aggregating_message: false, + is_structured: false, + }; + + let events = map_event(choice, &mut state); + + let [Err(error)] = events.as_slice() else { + panic!("expected exactly one error event, got {events:?}"); + }; + assert_eq!(error.kind, StreamErrorKind::Transient); + assert!(error.is_retryable()); + Ok(()) +} + fn forced_tool_request( reasoning: ReasoningDetails, enable_reasoning: bool, diff --git a/crates/jp_openrouter/src/types/response.rs b/crates/jp_openrouter/src/types/response.rs index 29a96e371..163c319db 100644 --- a/crates/jp_openrouter/src/types/response.rs +++ b/crates/jp_openrouter/src/types/response.rs @@ -65,6 +65,15 @@ pub struct ChatCompletion { /// Usage statistics for the completion request. pub usage: Option, + + /// The error that terminated the response, if any. + /// + /// A request that fails once the response has been committed cannot change + /// its HTTP status, so the failure arrives as a regular chunk carrying this + /// field alongside a `choices` entry with `finish_reason: "error"` (or an + /// empty `choices` array). + /// Present only on such terminating chunks. + pub error: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/jp_openrouter/tests/fixtures/test_chat_completion.snap b/crates/jp_openrouter/tests/fixtures/test_chat_completion.snap index cf561d3f5..bf3f2b163 100644 --- a/crates/jp_openrouter/tests/fixtures/test_chat_completion.snap +++ b/crates/jp_openrouter/tests/fixtures/test_chat_completion.snap @@ -49,5 +49,6 @@ Ok( total_tokens: 51, }, ), + error: None, }, ) diff --git a/crates/jp_openrouter/tests/fixtures/test_chat_completion_stream.snap b/crates/jp_openrouter/tests/fixtures/test_chat_completion_stream.snap index 972aee422..534d41049 100644 --- a/crates/jp_openrouter/tests/fixtures/test_chat_completion_stream.snap +++ b/crates/jp_openrouter/tests/fixtures/test_chat_completion_stream.snap @@ -31,6 +31,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -60,6 +61,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -89,6 +91,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -118,6 +121,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -147,6 +151,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -176,6 +181,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -205,6 +211,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -234,6 +241,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -263,6 +271,7 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ChatCompletion { id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E", @@ -292,5 +301,6 @@ expression: v object: ChatCompletionChunk, system_fingerprint: None, usage: None, + error: None, }, ]