Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions crates/jp_llm/src/provider/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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![];
Expand Down
102 changes: 102 additions & 0 deletions crates/jp_llm/src/provider/openrouter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions crates/jp_openrouter/src/types/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ pub struct ChatCompletion {

/// Usage statistics for the completion request.
pub usage: Option<Usage>,

/// 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<ErrorResponse>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ Ok(
total_tokens: 51,
},
),
error: None,
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -60,6 +61,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -89,6 +91,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -118,6 +121,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -147,6 +151,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -176,6 +181,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -205,6 +211,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -234,6 +241,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -263,6 +271,7 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
ChatCompletion {
id: "gen-1771242469-Y6Pl7tMhHtBIFcYGon8E",
Expand Down Expand Up @@ -292,5 +301,6 @@ expression: v
object: ChatCompletionChunk,
system_fingerprint: None,
usage: None,
error: None,
},
]
Loading