Skip to main content

allama_http/
error.rs

1//! Shared helpers for mapping HTTP responses to [`allama::Error`].
2//!
3//! Every HTTP provider shares:
4//!
5//! - what a status means on its own ([`status_error`]), which each provider
6//!   refines from its own error codes and body;
7//! - reading the status line and headers into an [`ErrorContext`] (see
8//!   [`error_context`]);
9//! - reading the common JSON error envelopes, keeping the provider's text
10//!   out of the redacted [`ErrorLog`](allama::ErrorLog) (see
11//!   [`error_envelope`]);
12//! - the error for a success response whose body cannot be decoded
13//!   ([`malformed_response`]).
14
15use allama::{Error, ErrorContext};
16use serde_json::{Map, Value};
17
18use crate::Headers;
19
20/// The response headers that carry a request id, tried in order: OpenAI and
21/// most compatible servers send `x-request-id`, Anthropic `request-id`.
22const REQUEST_ID_HEADERS: [&str; 2] = ["x-request-id", "request-id"];
23
24/// The diagnostic context of an HTTP error response, read from its status
25/// and headers:
26///
27/// - [`status`](ErrorContext::status);
28/// - [`request_id`](ErrorContext::request_id) from `x-request-id` or
29///   `request-id`;
30/// - [`retry_after`](ErrorContext::retry_after) from `Retry-After` or
31///   `retry-after-ms` (see [`Headers::retry_after`]);
32/// - [`server_retry_directive`](ErrorContext::server_retry_directive) from
33///   `x-should-retry: true` or `false`; any other value is no directive.
34///
35/// Every bundled HTTP provider builds its error context here, so a header
36/// rule added later reaches third-party providers that call it too. Attach
37/// it with [`Error::with_context`], which merges: it sets the fields read
38/// here, keeps the others, and never changes the
39/// [message origin](Error::authored). A provider that names its request id
40/// or retry hint header differently sets that field after attaching this
41/// context (`with_request_id`, `with_retry_after`), so a standard header
42/// does not overwrite it.
43///
44/// ```
45/// use allama::Error;
46/// use allama_http::{Headers, error_context, error_envelope, status_error};
47///
48/// fn map_http_error(status: u16, headers: &Headers, body: &str) -> Error {
49///     let error = status_error(status, format!("HTTP {status}"));
50///     error_envelope(body)
51///         .unwrap_or_default()
52///         .apply(error.with_context(error_context(status, headers)).authored())
53///         .with_body(body.to_owned())
54/// }
55///
56/// let headers: Headers = [
57///     ("x-request-id", "req_1"),
58///     ("retry-after", "2"),
59///     ("x-should-retry", "false"),
60/// ]
61/// .into_iter()
62/// .collect();
63/// let err = map_http_error(429, &headers, r#"{"error": {"message": "slow down"}}"#);
64/// assert_eq!(err.status(), Some(429));
65/// assert_eq!(err.request_id(), Some("req_1"));
66/// assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
67/// assert_eq!(err.server_retry_directive(), Some(false));
68/// assert_eq!(err.upstream_message(), Some("slow down"));
69/// ```
70#[must_use]
71pub fn error_context(status: u16, headers: &Headers) -> ErrorContext {
72    let mut context = ErrorContext::new();
73    context.status = Some(status);
74    context.request_id = REQUEST_ID_HEADERS
75        .iter()
76        .filter_map(|name| headers.get(name))
77        .find_map(non_empty);
78    context.retry_after = headers.retry_after("retry-after");
79    context.server_retry_directive = headers.get("x-should-retry").and_then(retry_directive);
80    context
81}
82
83/// The error an HTTP error status indicates on its own, before the body is
84/// read.
85///
86/// | Status | Error |
87/// |---|---|
88/// | 3xx | [`Error::UnexpectedResponse`], not retryable (a redirect, which is never followed: see [`redirect_error`](crate::redirect_error)) |
89/// | 401, 403 | [`Error::Auth`] |
90/// | 402 | [`Error::QuotaExhausted`] |
91/// | 408, 504, 524 | [`Error::Timeout`] |
92/// | 429 | [`Error::RateLimited`] |
93/// | 499 | [`Error::Provider`], not retryable |
94/// | other 4xx | [`Error::InvalidRequest`] |
95/// | 503, 529 | [`Error::Overloaded`] |
96/// | 501, 505 | [`Error::Provider`], not retryable |
97/// | anything else | [`Error::Provider`], retryable when 5xx |
98///
99/// Every bundled HTTP provider falls back to it after its own error codes
100/// and body, so one status means the same thing on every provider. Refine
101/// it from what the body says: a quota code on a `429` is
102/// [`Error::QuotaExhausted`], a `404` that names the model is
103/// [`Error::ModelNotFound`] (a bare `404` may be a wrong route, so it stays
104/// [`Error::InvalidRequest`]), and a `400` about the prompt's length is
105/// [`Error::ContextLengthExceeded`].
106///
107/// - `408` (the server gave up waiting for the request), `504` (a gateway
108///   gave up waiting for the model) and `524` (Cloudflare's origin timeout)
109///   are timeouts, which retry policies retry. A `504` or `524` can come
110///   after the model ran, so the request may still be billed.
111/// - `529` is Anthropic's overload status.
112/// - `499` means the client closed the request (nginx; `CANCELLED` in
113///   `google.rpc.Code`). It is a [`Error::Provider`] with the status, which
114///   is not retryable: the cancellation came from the caller's side, so it
115///   is neither retried nor reported as a rejected request.
116/// - `501` (Not Implemented) and `505` (HTTP Version Not Supported) are
117///   [`Error::Provider`]s that are not retryable: the server answers them
118///   the same way every time. A `501` stays a provider error rather than
119///   [`Error::Unsupported`], whose `Provider` stage comes only from an
120///   adapter's own table of the replies it recognises (ADR 0003).
121///
122/// `message` is the adapter's own text, such as `HTTP {status}`. The error
123/// carries the status and nothing else: add the rest of its context with
124/// [`error_context`] and [`Error::with_context`], and mark the message
125/// [`authored`](Error::authored).
126///
127/// ```
128/// use allama::Error;
129/// use allama_http::status_error;
130///
131/// assert!(matches!(status_error(504, "HTTP 504"), Error::Timeout { .. }));
132/// assert!(matches!(status_error(529, "HTTP 529"), Error::Overloaded { .. }));
133/// let cancelled = status_error(499, "HTTP 499");
134/// assert!(matches!(cancelled, Error::Provider { .. }));
135/// assert_eq!(cancelled.status(), Some(499));
136/// assert!(!cancelled.is_retryable());
137/// assert!(!status_error(501, "HTTP 501").is_retryable());
138/// assert!(status_error(502, "HTTP 502").is_retryable());
139/// assert!(matches!(status_error(307, "HTTP 307"), Error::UnexpectedResponse { .. }));
140/// ```
141#[must_use]
142pub fn status_error(status: u16, message: impl Into<String>) -> Error {
143    let message = message.into();
144    let error = match status {
145        300..=399 => Error::unexpected_response(message),
146        401 | 403 => Error::auth(message),
147        402 => Error::quota_exhausted(message),
148        408 | 504 | 524 => Error::timeout(message),
149        429 => Error::rate_limited(message),
150        499 => Error::provider(message),
151        400..=498 => Error::invalid_request(message),
152        503 | 529 => Error::overloaded(message),
153        _ => Error::provider(message),
154    };
155    error.with_status(status)
156}
157
158/// Recognise a JSON error envelope in an error response body or an in-band
159/// error event.
160///
161/// Recognised shapes:
162///
163/// - OpenAI: `{"error": {"message", "type", "code", "param"}}`. Current vLLM
164///   and Ollama's OpenAI-compatible `/v1` endpoints send it too (vLLM with a
165///   numeric `code`), and Anthropic's `{"type": "error", "error": {"type",
166///   "message"}}` reads the same way.
167/// - Legacy vLLM, and other bare error objects:
168///   `{"object": "error", "message", "type", "param", "code"}`.
169/// - Native Ollama: `{"error": "message"}`.
170///
171/// Anything else, including an empty or non-JSON body, returns `None`.
172///
173/// The envelope's `message` is the provider's own text. Attach it with
174/// [`ErrorEnvelope::apply`], which puts it in
175/// [`Error::with_upstream_message`]. Never put it in the constructor message
176/// of an [`authored`](Error::authored) error: upstream text may echo prompts
177/// or input values. The constructor message is the adapter's own text; for
178/// HTTP errors the bundled providers use `HTTP {status}`, with or without an
179/// envelope:
180///
181/// ```
182/// use allama::Error;
183/// use allama_http::{error_envelope, status_error};
184///
185/// fn map_http_error(status: u16, body: &str) -> Error {
186///     let envelope = error_envelope(body).unwrap_or_default();
187///     envelope
188///         .apply(status_error(status, format!("HTTP {status}")).authored())
189///         .with_body(body.to_owned())
190/// }
191///
192/// let err = map_http_error(
193///     400,
194///     r#"{"error": {"message": "bad value 'my SSN is 123-45-6789'", "param": "messages.[0].content"}}"#,
195/// );
196/// assert_eq!(
197///     err.as_log().to_string(),
198///     r#"kind=invalid_request status=400 param=messages.[0].content body_len=92 message="HTTP 400""#,
199/// );
200/// assert_eq!(err.upstream_message(), Some("bad value 'my SSN is 123-45-6789'"));
201///
202/// // An unrecognised body never reaches the message.
203/// let err = map_http_error(502, "<html>my SSN is 123-45-6789</html>");
204/// assert_eq!(err.to_string(), "provider error (502): HTTP 502");
205/// ```
206///
207/// A caller that already parsed the body (an NDJSON line, an SSE event's
208/// data) reads it with [`error_envelope_value`].
209#[must_use]
210pub fn error_envelope(body: &str) -> Option<ErrorEnvelope> {
211    let value = serde_json::from_str::<Value>(body.trim()).ok()?;
212    error_envelope_value(&value)
213}
214
215/// [`error_envelope`] of a body already parsed as JSON, for a caller that
216/// parsed it to tell an error from its other frames (an NDJSON line, an SSE
217/// event's data). It recognises the same shapes, listed on
218/// [`error_envelope`].
219///
220/// ```
221/// use allama_http::error_envelope_value;
222/// use serde_json::json;
223///
224/// let line = json!({"error": "model 'llama9' not found"});
225/// let envelope = error_envelope_value(&line).unwrap();
226/// assert_eq!(envelope.message.as_deref(), Some("model 'llama9' not found"));
227/// assert!(error_envelope_value(&json!({"message": {"role": "assistant"}})).is_none());
228/// ```
229#[must_use]
230pub fn error_envelope_value(value: &Value) -> Option<ErrorEnvelope> {
231    let Value::Object(object) = value else {
232        return None;
233    };
234    match object.get("error") {
235        Some(Value::Object(error)) => Some(ErrorEnvelope::from_object(error)),
236        Some(Value::String(message)) => Some(ErrorEnvelope {
237            message: non_empty(message),
238            ..ErrorEnvelope::default()
239        }),
240        Some(_) => None,
241        None if object.get("message").is_some_and(Value::is_string) => {
242            Some(ErrorEnvelope::from_object(object))
243        }
244        None => None,
245    }
246}
247
248/// The error for a success response whose body could not be decoded: an
249/// HTML page from a proxy, a truncated body, or JSON of another shape.
250///
251/// It is an [`Error::UnexpectedResponse`], which is neither retried nor
252/// failed over, with the `status`, the body (capped, and only its length
253/// reaches the log) and the decoder's error as the
254/// [upstream message](Error::upstream_message), since a decoder's text can
255/// quote the body. Add the provider and the request id, so the failure can
256/// be traced to a response:
257///
258/// ```
259/// use allama::Error;
260/// use allama_http::malformed_response;
261///
262/// let body = b"<html>502 Bad Gateway</html>";
263/// let decoded = serde_json::from_slice::<serde_json::Value>(body);
264/// let err = malformed_response(200, body, decoded.unwrap_err())
265///     .with_provider("acme")
266///     .with_request_id("req_1");
267/// assert!(matches!(err, Error::UnexpectedResponse { .. }));
268/// assert_eq!(err.status(), Some(200));
269/// assert_eq!(err.request_id(), Some("req_1"));
270/// assert_eq!(
271///     err.as_log().to_string(),
272///     r#"kind=unexpected_response provider=acme status=200 request_id=req_1 body_len=28 message="the response body could not be decoded""#,
273/// );
274/// ```
275///
276/// [`Error::Serialization`] is for failures of the caller's own
277/// (de)serialization, never for what a provider sent.
278#[must_use]
279pub fn malformed_response(status: u16, body: &[u8], err: impl std::fmt::Display) -> Error {
280    Error::unexpected_response("the response body could not be decoded")
281        .authored()
282        .with_status(status)
283        .with_upstream_message(err.to_string())
284        .with_body_opt(
285            (!body.is_empty())
286                .then(|| String::from_utf8_lossy(body))
287                .as_deref(),
288        )
289}
290
291/// The fields of a recognised JSON error envelope (see [`error_envelope`]).
292#[derive(Debug, Clone, Default, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct ErrorEnvelope {
295    /// The provider's error text. Upstream text: it belongs in
296    /// [`Error::with_upstream_message`], never in an authored message.
297    pub message: Option<String>,
298    /// Provider error code. Numeric codes are kept as their decimal text.
299    pub code: Option<String>,
300    /// Provider error type or category.
301    pub error_type: Option<String>,
302    /// The path of the rejected request field, such as
303    /// `messages.[1].content`.
304    pub param: Option<String>,
305}
306
307impl ErrorEnvelope {
308    /// Attach the envelope to `error`: the message as its
309    /// [upstream message](Error::with_upstream_message), the code, type and
310    /// rejected field as its provider code, provider type and
311    /// [`param`](Error::with_param).
312    #[must_use]
313    pub fn apply(self, error: Error) -> Error {
314        error
315            .with_upstream_message_opt(self.message.as_deref())
316            .with_provider_code_opt(self.code.as_deref())
317            .with_provider_type_opt(self.error_type.as_deref())
318            .with_param_opt(self.param.as_deref())
319    }
320
321    fn from_object(object: &Map<String, Value>) -> Self {
322        Self {
323            message: object
324                .get("message")
325                .and_then(Value::as_str)
326                .and_then(non_empty),
327            code: object.get("code").and_then(scalar_text),
328            error_type: object.get("type").and_then(scalar_text),
329            param: object.get("param").and_then(scalar_text),
330        }
331    }
332}
333
334/// A string or number as text; `None` for null, empty strings, and other
335/// values.
336fn scalar_text(value: &Value) -> Option<String> {
337    match value {
338        Value::String(text) => non_empty(text),
339        Value::Number(number) => Some(number.to_string()),
340        _ => None,
341    }
342}
343
344fn non_empty(text: &str) -> Option<String> {
345    (!text.trim().is_empty()).then(|| text.to_owned())
346}
347
348/// An `x-should-retry` value: `true` or `false`, ignoring case and
349/// surrounding whitespace.
350fn retry_directive(value: &str) -> Option<bool> {
351    let value = value.trim();
352    if value.eq_ignore_ascii_case("true") {
353        Some(true)
354    } else if value.eq_ignore_ascii_case("false") {
355        Some(false)
356    } else {
357        None
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn envelope(message: &str, code: Option<&str>, error_type: Option<&str>) -> ErrorEnvelope {
366        ErrorEnvelope {
367            message: Some(message.into()),
368            code: code.map(Into::into),
369            error_type: error_type.map(Into::into),
370            param: None,
371        }
372    }
373
374    fn headers(pairs: &[(&str, &str)]) -> Headers {
375        pairs.iter().copied().collect()
376    }
377
378    #[test]
379    fn error_context_reads_status_and_headers() {
380        let context = error_context(
381            409,
382            &headers(&[
383                ("X-Request-Id", "req_1"),
384                ("Retry-After", "3"),
385                ("X-Should-Retry", "true"),
386            ]),
387        );
388        assert_eq!(context.status, Some(409));
389        assert_eq!(context.request_id.as_deref(), Some("req_1"));
390        assert_eq!(context.retry_after, Some(std::time::Duration::from_secs(3)));
391        assert_eq!(context.server_retry_directive, Some(true));
392        // Nothing else: classification and the body stay with the provider.
393        let mut rest = context;
394        rest.status = None;
395        rest.request_id = None;
396        rest.retry_after = None;
397        rest.server_retry_directive = None;
398        assert_eq!(rest, ErrorContext::new());
399
400        let mut bare = error_context(500, &Headers::new());
401        assert_eq!(bare.status, Some(500));
402        bare.status = None;
403        assert_eq!(bare, ErrorContext::new());
404    }
405
406    #[test]
407    fn error_context_request_id_headers() {
408        let anthropic = error_context(429, &headers(&[("request-id", "req_a")]));
409        assert_eq!(anthropic.request_id.as_deref(), Some("req_a"));
410        let both = error_context(
411            429,
412            &headers(&[("request-id", "req_a"), ("x-request-id", "req_x")]),
413        );
414        assert_eq!(both.request_id.as_deref(), Some("req_x"));
415        let empty = error_context(
416            429,
417            &headers(&[("x-request-id", " "), ("request-id", "req_a")]),
418        );
419        assert_eq!(empty.request_id.as_deref(), Some("req_a"));
420    }
421
422    #[test]
423    fn error_context_retry_directive_values() {
424        for (value, expected) in [
425            ("true", Some(true)),
426            ("false", Some(false)),
427            (" TRUE ", Some(true)),
428            ("False", Some(false)),
429            ("1", None),
430            ("yes", None),
431            ("", None),
432        ] {
433            let context = error_context(503, &headers(&[("x-should-retry", value)]));
434            assert_eq!(context.server_retry_directive, expected, "{value:?}");
435        }
436        let context = error_context(429, &headers(&[("retry-after-ms", "1500")]));
437        assert_eq!(
438            context.retry_after,
439            Some(std::time::Duration::from_millis(1500))
440        );
441    }
442
443    #[test]
444    fn status_error_kinds() {
445        for (status, kind) in [
446            (400, "invalid_request"),
447            (401, "auth"),
448            (402, "quota_exhausted"),
449            (403, "auth"),
450            (404, "invalid_request"),
451            (408, "timeout"),
452            (409, "invalid_request"),
453            (413, "invalid_request"),
454            (422, "invalid_request"),
455            (429, "rate_limited"),
456            (499, "provider"),
457            (500, "provider"),
458            (502, "provider"),
459            (503, "overloaded"),
460            (504, "timeout"),
461            (524, "timeout"),
462            (529, "overloaded"),
463            (600, "provider"),
464        ] {
465            let err = status_error(status, format!("HTTP {status}"));
466            assert_eq!(err.kind_name(), kind, "HTTP {status}");
467            assert_eq!(err.status(), Some(status));
468            assert_eq!(err.message(), format!("HTTP {status}"));
469        }
470    }
471
472    #[test]
473    fn a_closed_request_is_neither_retried_nor_blamed_on_the_request() {
474        let err = status_error(499, "HTTP 499");
475        assert!(matches!(err, Error::Provider { .. }), "{err:?}");
476        assert!(!err.is_retryable());
477        assert!(!err.is_transient());
478    }
479
480    #[test]
481    fn timeouts_are_retryable() {
482        for status in [408, 504, 524] {
483            assert!(status_error(status, "x").is_retryable(), "{status}");
484        }
485    }
486
487    #[test]
488    fn malformed_responses_keep_the_body_out_of_the_log() {
489        let body = b"<html>my SSN is 123-45-6789</html>";
490        let err = malformed_response(200, body, "expected value at line 1 column 1");
491        assert!(matches!(err, Error::UnexpectedResponse { .. }), "{err:?}");
492        assert!(!err.is_retryable());
493        assert_eq!(err.status(), Some(200));
494        assert_eq!(
495            err.upstream_message(),
496            Some("expected value at line 1 column 1")
497        );
498        assert_eq!(
499            err.context().unwrap().body.as_deref(),
500            Some("<html>my SSN is 123-45-6789</html>")
501        );
502        assert!(!err.as_log().to_string().contains("SSN"));
503        assert_eq!(
504            malformed_response(200, b"", "EOF").context().unwrap().body,
505            None
506        );
507    }
508
509    #[test]
510    fn openai_shape() {
511        let parsed = error_envelope(
512            r#"{"error": {"message": "Invalid value", "type": "invalid_request_error", "code": "invalid_value", "param": "messages.[1].content"}}"#,
513        )
514        .unwrap();
515        assert_eq!(
516            parsed,
517            ErrorEnvelope {
518                param: Some("messages.[1].content".into()),
519                ..envelope(
520                    "Invalid value",
521                    Some("invalid_value"),
522                    Some("invalid_request_error")
523                )
524            }
525        );
526    }
527
528    #[test]
529    fn current_vllm_and_ollama_v1_shape() {
530        // vLLM `ErrorInfo`: the OpenAI shape with a numeric code.
531        let vllm = error_envelope(
532            r#"{"error": {"message": "max_tokens must be at least 1", "type": "BadRequestError", "param": null, "code": 400}}"#,
533        )
534        .unwrap();
535        assert_eq!(
536            vllm,
537            envelope(
538                "max_tokens must be at least 1",
539                Some("400"),
540                Some("BadRequestError")
541            )
542        );
543
544        // Ollama `/v1` (`openai.NewError`).
545        let ollama = error_envelope(
546            r#"{"error": {"message": "model \"x\" not found", "type": "api_error", "param": null, "code": null}}"#,
547        )
548        .unwrap();
549        assert_eq!(
550            ollama,
551            envelope("model \"x\" not found", None, Some("api_error"))
552        );
553    }
554
555    #[test]
556    fn legacy_vllm_shape() {
557        let parsed = error_envelope(
558            r#"{"object": "error", "message": "bad request", "type": "BadRequestError", "param": null, "code": 400}"#,
559        )
560        .unwrap();
561        assert_eq!(
562            parsed,
563            envelope("bad request", Some("400"), Some("BadRequestError"))
564        );
565    }
566
567    #[test]
568    fn native_ollama_shape() {
569        let parsed = error_envelope(r#"{"error": "model 'x' not found"}"#).unwrap();
570        assert_eq!(parsed, envelope("model 'x' not found", None, None));
571    }
572
573    #[test]
574    fn a_parsed_body_reads_as_its_text() {
575        let bodies = [
576            r#"{"error": {"message": "bad", "type": "invalid_request_error", "code": "x"}}"#,
577            r#"{"object": "error", "message": "bad", "type": "BadRequestError", "code": 400}"#,
578            r#"{"error": "model 'x' not found"}"#,
579            r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#,
580            r#"{"message": {"role": "assistant", "content": "hi"}}"#,
581            r#"{"error": 3}"#,
582            r#"["error"]"#,
583            r#""error""#,
584        ];
585        for body in bodies {
586            let value: Value = serde_json::from_str(body).unwrap();
587            assert_eq!(error_envelope_value(&value), error_envelope(body), "{body}");
588        }
589    }
590
591    #[test]
592    fn anthropic_shape_reads_as_openai() {
593        let parsed = error_envelope(
594            r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_1"}"#,
595        )
596        .unwrap();
597        assert_eq!(
598            parsed,
599            envelope("Overloaded", None, Some("overloaded_error"))
600        );
601    }
602
603    #[test]
604    fn unknown_bodies_are_not_envelopes() {
605        for body in [
606            "",
607            "   ",
608            "<html>my SSN is 123-45-6789</html>",
609            "upstream connect error",
610            "[1, 2]",
611            r#"{"detail": "Not Found"}"#,
612            r#"{"error": 42}"#,
613            r#"{"message": 42}"#,
614        ] {
615            assert_eq!(error_envelope(body), None, "{body}");
616        }
617    }
618
619    #[test]
620    fn empty_messages_are_absent() {
621        let parsed =
622            error_envelope(r#"{"error": {"message": "", "code": "server_error"}}"#).unwrap();
623        assert_eq!(parsed.message, None);
624        assert_eq!(parsed.code.as_deref(), Some("server_error"));
625    }
626
627    #[test]
628    fn apply_keeps_upstream_text_out_of_the_log() {
629        let err = error_envelope(
630            r#"{"error": {"message": "SENTINEL", "type": "invalid_request_error", "code": "bad", "param": "tools[0].function.parameters"}}"#,
631        )
632        .unwrap()
633        .apply(Error::invalid_request("HTTP 400").authored());
634        assert_eq!(err.upstream_message(), Some("SENTINEL"));
635        let context = err.context().unwrap();
636        assert_eq!(context.provider_code.as_deref(), Some("bad"));
637        assert_eq!(
638            context.provider_type.as_deref(),
639            Some("invalid_request_error")
640        );
641        assert_eq!(
642            context.param.as_deref(),
643            Some("tools[0].function.parameters")
644        );
645        assert_eq!(
646            err.as_log().to_string(),
647            r#"kind=invalid_request provider_code=bad provider_type=invalid_request_error param=tools[0].function.parameters message="HTTP 400""#
648        );
649    }
650}