Skip to main content

sqlite_graphrag/
chat_api.rs

1//! HTTP client for the OpenRouter chat-completions API.
2//!
3//! Sends structured-output chat requests to the OpenAI-compatible endpoint
4//! at `openrouter.ai/api/v1/chat/completions` and returns the parsed JSON
5//! object the model produced under a strict `json_schema` `response_format`.
6//!
7//! This mirrors [`crate::embedding_api`] for the embeddings endpoint: same
8//! retry/backoff policy (immediate abort on 401/400/404, `retry-after` on
9//! 429, exponential backoff + jitter on 5xx) and the same minimal headers
10//! (only `Authorization: Bearer`, no `HTTP-Referer`/`X-Title`). The shared
11//! error envelope and backoff helper live in [`crate::openrouter_http`]
12//! (GAP-SG-74).
13//!
14//! v1.0.95 (ADR-0054): adds an OpenRouter REST transport for the `enrich`
15//! JUDGE so structured extraction no longer requires a locally installed
16//! `claude` / `codex` / `opencode` CLI subprocess.
17//!
18//! v1.1.00 (GAP-SG-70/72-chat): the OpenAI-compatible contract surfaces
19//! `choices[].finish_reason` and `usage.{prompt_tokens,completion_tokens}`.
20//! `finish_reason == "length"` means the response was truncated because
21//! `max_tokens` was too small — not a malformed generation.
22//! [`OpenRouterChatClient::complete`](crate::chat_api::OpenRouterChatClient::complete)
23//! now detects this BEFORE attempting JSON repair, grows `max_tokens` and
24//! re-issues the request (bounded by
25//! [`crate::constants::ENRICH_MAX_LENGTH_RETRIES`]), and always reports the
26//! diagnostics (`finish_reason`, token counts) to the caller via
27//! [`ChatCompletion`](crate::chat_api::ChatCompletion) on success or
28//! [`ChatError`](crate::chat_api::ChatError) on failure.
29
30use crate::errors::AppError;
31use crate::retry::AttemptOutcome;
32use secrecy::{ExposeSecret, SecretBox};
33use serde::{Deserialize, Serialize};
34use std::time::Duration;
35
36use crate::constants::DEFAULT_OPENROUTER_CHAT_URL;
37// GAP-SG-17: raised from 300 to 600 — the per-request fallback budget when a
38// caller passes `0`. Dense bodies near the model's ~32K-token context ceiling
39// regularly need more than five minutes to generate.
40const DEFAULT_TIMEOUT_SECS: u64 = 600;
41const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
42
43/// Fixed `json_schema` name sent in the `response_format`. OpenRouter only
44/// requires a short identifier; the actual contract is carried by `schema`.
45const SCHEMA_NAME: &str = "enrich_output";
46
47#[derive(Serialize)]
48struct ChatRequest<'a> {
49    model: &'a str,
50    messages: Vec<ChatMessage<'a>>,
51    response_format: ResponseFormat,
52    provider: ProviderPrefs,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    reasoning: Option<ReasoningPrefs>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    max_tokens: Option<u32>,
57}
58
59#[derive(Serialize)]
60struct ChatMessage<'a> {
61    role: &'a str,
62    content: String,
63}
64
65#[derive(Serialize)]
66struct ResponseFormat {
67    #[serde(rename = "type")]
68    format_type: &'static str,
69    json_schema: JsonSchemaSpec,
70}
71
72#[derive(Serialize)]
73struct JsonSchemaSpec {
74    name: &'static str,
75    strict: bool,
76    schema: serde_json::Value,
77}
78
79#[derive(Serialize)]
80struct ProviderPrefs {
81    require_parameters: bool,
82}
83
84#[derive(Serialize)]
85struct ReasoningPrefs {
86    enabled: bool,
87}
88
89#[derive(Deserialize)]
90struct ChatResponse {
91    #[serde(default)]
92    choices: Vec<Choice>,
93    #[serde(default)]
94    usage: Option<Usage>,
95    /// Structured provider error. OpenRouter may return this inside an HTTP 200
96    /// body (e.g. token/context-length overflow); without it the response would
97    /// parse into empty `choices` and surface the misleading "no structured
98    /// content" error instead of the real cause (GAP-SG-03).
99    #[serde(default)]
100    error: Option<crate::openrouter_http::ApiError>,
101}
102
103#[derive(Deserialize)]
104struct Choice {
105    message: RespMessage,
106    /// Why the model stopped generating: `"stop"` on a normal completion,
107    /// `"length"` when `max_tokens` cut the response short (GAP-SG-70/72-chat).
108    /// Absent from providers that omit it, hence `#[serde(default)]`.
109    #[serde(default)]
110    finish_reason: Option<String>,
111}
112
113#[derive(Deserialize)]
114struct RespMessage {
115    #[serde(default)]
116    content: Option<String>,
117}
118
119#[derive(Deserialize)]
120struct Usage {
121    #[serde(default)]
122    cost: Option<f64>,
123    /// Prompt token count reported by OpenRouter (GAP-SG-72-chat). Diagnostic
124    /// only — never used to gate control flow, so a missing value stays `None`.
125    #[serde(default)]
126    prompt_tokens: Option<u32>,
127    /// Completion token count reported by OpenRouter (GAP-SG-72-chat), used
128    /// alongside `finish_reason` to explain a truncated response.
129    #[serde(default)]
130    completion_tokens: Option<u32>,
131}
132
133/// Successful [`OpenRouterChatClient::complete`] result (GAP-SG-72-chat).
134///
135/// `finish_reason`, `prompt_tokens` and `completion_tokens` are the raw
136/// diagnostics OpenRouter attached to the response that ultimately succeeded
137/// (after any `max_tokens` growth retries — see [`Self::value`] and the
138/// module docs). They are `None` only when the provider omitted them.
139#[derive(Debug)]
140pub struct ChatCompletion {
141    /// Model output parsed as JSON (guaranteed to be a JSON object).
142    pub value: serde_json::Value,
143    /// Cost in USD read from `usage.cost`, or `0.0` when the provider omitted it.
144    pub cost_usd: f64,
145    /// `choices[0].finish_reason` from the response that produced `value`.
146    pub finish_reason: Option<String>,
147    /// `usage.prompt_tokens` from the response that produced `value`.
148    pub prompt_tokens: Option<u32>,
149    /// `usage.completion_tokens` from the response that produced `value`.
150    pub completion_tokens: Option<u32>,
151}
152
153/// [`OpenRouterChatClient::complete`] failure (GAP-SG-72-chat / GAP-SG-72
154/// reauditor addendum).
155///
156/// Wraps the underlying [`AppError`] with whatever truncation diagnostics were
157/// available at the point of failure. `finish_reason`/token fields are `None`
158/// when the failure happened before a response was parsed (network error, a
159/// permanent 4xx, or exhausted retries) — only failures that occur AFTER a
160/// `ChatResponse` was successfully decoded (JSON-repair or shape-guard
161/// failures) carry them.
162///
163/// `retry_class` is the retry verdict computed AT THE ORIGIN (the exact HTTP
164/// status, or the provider's structured error `code`), never inferred
165/// downstream from `source.to_string()`. The enrich queue consumes this field
166/// directly instead of pattern-matching the formatted message.
167#[derive(Debug)]
168pub struct ChatError {
169    /// Underlying cause, preserved via `source()` rather than restated.
170    pub source: AppError,
171    /// `choices[0].finish_reason` from the response that led to this error,
172    /// when one was decoded.
173    pub finish_reason: Option<String>,
174    /// `usage.prompt_tokens` from the response that led to this error, when
175    /// one was decoded.
176    pub prompt_tokens: Option<u32>,
177    /// `usage.completion_tokens` from the response that led to this error,
178    /// when one was decoded.
179    pub completion_tokens: Option<u32>,
180    /// Typed retry verdict computed where the failure originated (HTTP
181    /// status / provider code), not by matching `source`'s message.
182    pub retry_class: AttemptOutcome,
183}
184
185impl ChatError {
186    /// Wraps `source` with no diagnostics attached (used when no
187    /// `ChatResponse` was decoded before the failure) and the `retry_class`
188    /// computed by the caller at the exact HTTP status / provider code.
189    fn new(source: AppError, retry_class: AttemptOutcome) -> Self {
190        Self {
191            source,
192            finish_reason: None,
193            prompt_tokens: None,
194            completion_tokens: None,
195            retry_class,
196        }
197    }
198
199    /// Wraps `source` with the diagnostics captured from a decoded
200    /// `ChatResponse` that nonetheless failed downstream (repair or
201    /// shape-guard), plus its `retry_class`.
202    fn with_diagnostics(
203        source: AppError,
204        finish_reason: Option<String>,
205        prompt_tokens: Option<u32>,
206        completion_tokens: Option<u32>,
207        retry_class: AttemptOutcome,
208    ) -> Self {
209        Self {
210            source,
211            finish_reason,
212            prompt_tokens,
213            completion_tokens,
214            retry_class,
215        }
216    }
217}
218
219impl std::fmt::Display for ChatError {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        std::fmt::Display::fmt(&self.source, f)
222    }
223}
224
225impl std::error::Error for ChatError {
226    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
227        Some(&self.source)
228    }
229}
230
231/// Process-wide OpenRouter chat client. Holds the model name so that callers
232/// only thread the per-item prompt/schema/input through [`Self::complete`].
233pub struct OpenRouterChatClient {
234    client: reqwest::Client,
235    api_key: SecretBox<String>,
236    model: String,
237    /// Endpoint each request is POSTed to. Resolved from XDG/config at
238    /// construction (default: [`DEFAULT_OPENROUTER_CHAT_URL`]).
239    base_url: String,
240}
241
242impl OpenRouterChatClient {
243    /// Builds a chat client bound to `model`, applying `timeout_secs` as the
244    /// total per-request budget (wired from `--openrouter-timeout`). A value of
245    /// `0` falls back to `DEFAULT_TIMEOUT_SECS` so a missing or zero flag never
246    /// degrades into reqwest`'s immediate-timeout behaviour.
247    pub fn new(
248        api_key: SecretBox<String>,
249        model: String,
250        timeout_secs: u64,
251    ) -> Result<Self, AppError> {
252        let base_url =
253            crate::runtime_config::openrouter_chat_url(DEFAULT_OPENROUTER_CHAT_URL);
254        Self::new_with_base_url(api_key, model, timeout_secs, base_url)
255    }
256
257    /// Build a client posting to an explicit `base_url` (XDG override, tests, gateways).
258    pub fn new_with_base_url(
259        api_key: SecretBox<String>,
260        model: String,
261        timeout_secs: u64,
262        base_url: String,
263    ) -> Result<Self, AppError> {
264        let timeout_secs = if timeout_secs == 0 {
265            DEFAULT_TIMEOUT_SECS
266        } else {
267            timeout_secs
268        };
269        let client = reqwest::Client::builder()
270            .timeout(Duration::from_secs(timeout_secs))
271            .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
272            .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
273            .build()
274            .map_err(|e| {
275                AppError::Validation(crate::i18n::validation::http_client_build_failed(&e))
276            })?;
277
278        Ok(Self {
279            client,
280            api_key,
281            model,
282            base_url,
283        })
284    }
285
286    /// Test-only constructor that POSTs to an arbitrary `base_url`.
287    #[cfg(test)]
288    pub fn new_with_url(
289        api_key: SecretBox<String>,
290        model: String,
291        base_url: String,
292        timeout_secs: u64,
293    ) -> Result<Self, AppError> {
294        Self::new_with_base_url(api_key, model, timeout_secs, base_url)
295    }
296
297    /// Returns the model bound to this client.
298    pub fn model(&self) -> &str {
299        &self.model
300    }
301
302    /// Runs a single structured-output completion, transparently growing
303    /// `max_tokens` and re-issuing the request when the model truncates its
304    /// output (GAP-SG-70).
305    ///
306    /// `schema_str` is the JSON Schema (as a string) the model must honour
307    /// under `strict: true`. When `input_text` is empty only the system
308    /// message is sent. `max_tokens` seeds the first attempt; `None` lets the
309    /// provider apply its own default.
310    ///
311    /// Returns [`ChatCompletion`] on success or [`ChatError`] on failure; both
312    /// carry `finish_reason`/token diagnostics when a response was decoded.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`ChatError`] when: the schema is invalid JSON; the HTTP
317    /// request fails or exhausts retries; the provider returns a permanent
318    /// error (401/400/404, or a structured `error` object in a 2xx body); the
319    /// response carries no usable content; the content cannot be parsed as
320    /// JSON even after repair; the parsed JSON is not an object; or the
321    /// response is truncated (`finish_reason: "length"`) after
322    /// [`crate::constants::ENRICH_MAX_LENGTH_RETRIES`] `max_tokens` growth
323    /// attempts are exhausted.
324    pub async fn complete(
325        &self,
326        system_prompt: &str,
327        input_text: &str,
328        schema_str: &str,
329        max_tokens: Option<u32>,
330    ) -> Result<ChatCompletion, ChatError> {
331        // A malformed schema is a permanent caller/config error — classified
332        // explicitly (no blanket `From<AppError>` conversion exists for this
333        // type; every `ChatError` states its `retry_class` at construction).
334        let schema: serde_json::Value = serde_json::from_str(schema_str).map_err(|e| {
335            ChatError::new(
336                AppError::Validation(crate::i18n::validation::invalid_json_schema_for_request(&e)),
337                AttemptOutcome::HardFailure,
338            )
339        })?;
340
341        let mut current_max_tokens = max_tokens;
342
343        for length_attempt in 0..=crate::constants::ENRICH_MAX_LENGTH_RETRIES {
344            let response = self
345                .complete_one_attempt(&schema, system_prompt, input_text, current_max_tokens)
346                .await?;
347
348            let finish_reason = response
349                .choices
350                .first()
351                .and_then(|c| c.finish_reason.clone());
352            let prompt_tokens = response.usage.as_ref().and_then(|u| u.prompt_tokens);
353            let completion_tokens = response.usage.as_ref().and_then(|u| u.completion_tokens);
354
355            let truncated = finish_reason.as_deref() == Some("length");
356            let retries_left = length_attempt < crate::constants::ENRICH_MAX_LENGTH_RETRIES;
357
358            if truncated && retries_left {
359                let next_max_tokens = grow_max_tokens(current_max_tokens);
360                tracing::warn!(
361                    model = %self.model,
362                    attempt = length_attempt,
363                    previous_max_tokens = ?current_max_tokens,
364                    next_max_tokens,
365                    "OpenRouter completion truncated (finish_reason=length); \
366                     retrying with a larger max_tokens budget"
367                );
368                current_max_tokens = Some(next_max_tokens);
369                continue;
370            }
371
372            if truncated {
373                tracing::warn!(
374                    model = %self.model,
375                    max_length_retries = crate::constants::ENRICH_MAX_LENGTH_RETRIES,
376                    max_tokens = ?current_max_tokens,
377                    "OpenRouter completion still truncated after exhausting \
378                     max_tokens growth"
379                );
380            }
381
382            return self.finish_completion(
383                response,
384                finish_reason,
385                prompt_tokens,
386                completion_tokens,
387            );
388        }
389
390        unreachable!("loop always returns within ENRICH_MAX_LENGTH_RETRIES + 1 iterations")
391    }
392
393    /// Runs one HTTP attempt (including the mandatory-reasoning fallback) and
394    /// returns the decoded [`ChatResponse`] without inspecting `finish_reason`
395    /// or extracting content — that happens in [`Self::complete`] so the
396    /// `max_tokens` growth loop can re-issue the request first.
397    async fn complete_one_attempt(
398        &self,
399        schema: &serde_json::Value,
400        system_prompt: &str,
401        input_text: &str,
402        max_tokens: Option<u32>,
403    ) -> Result<ChatResponse, ChatError> {
404        // First attempt sends reasoning.enabled=false (token savings on the
405        // ~9 models that allow disabling). The ~4 reasoning-mandatory models
406        // (e.g. minimax-m2.7, gpt-oss-120b) reject it with HTTP 400 mentioning
407        // "reasoning"; on that specific failure we retry ONCE with the
408        // reasoning field omitted so the model uses its mandatory default. Any
409        // other error, or a second failure, propagates the original error.
410        let primary = self.build_request(
411            schema.clone(),
412            system_prompt,
413            input_text,
414            max_tokens,
415            Some(ReasoningPrefs { enabled: false }),
416        );
417        match self.execute_with_retry(&primary).await {
418            Ok(r) => Ok(r),
419            Err(first_err) => {
420                if reasoning_disable_rejected(&first_err) {
421                    tracing::warn!(
422                        model = %self.model,
423                        "model rejected reasoning.enabled=false (mandatory); \
424                         retrying once with reasoning omitted"
425                    );
426                    let fallback = self.build_request(
427                        schema.clone(),
428                        system_prompt,
429                        input_text,
430                        max_tokens,
431                        None,
432                    );
433                    match self.execute_with_retry(&fallback).await {
434                        Ok(r) => Ok(r),
435                        Err(_) => Err(first_err),
436                    }
437                } else {
438                    Err(first_err)
439                }
440            }
441        }
442    }
443
444    /// Extracts content, repairs/parses it as JSON, and enforces the
445    /// object-shape guard, attaching `finish_reason`/token diagnostics to any
446    /// failure.
447    ///
448    /// Every failure branch below (missing content, JSON-repair failure,
449    /// non-object shape) classifies as `AttemptOutcome::Transient`. This is a
450    /// deliberate, acknowledged tension with `rules_rust_retry_com_backoff.md`
451    /// ("NUNCA retentar erros de parsing ou deserialização" / "NUNCA retentar
452    /// erros de deserialização"): those rules target DETERMINISTIC parse
453    /// errors, where retrying the identical input reproduces the identical
454    /// failure. Here the "input" is `deepseek-v4-flash:nitro` sampling
455    /// variance — the SAME prompt can legitimately produce well-formed JSON
456    /// on the next generation (see GAP-SG-10). So this is a typed, bounded
457    /// hiccup, not a retry-forever loophole: it is capped by `--max-attempts`
458    /// (GAP-SG-09/GAP-SG-21) and dead-letters once attempts are exhausted.
459    fn finish_completion(
460        &self,
461        response: ChatResponse,
462        finish_reason: Option<String>,
463        prompt_tokens: Option<u32>,
464        completion_tokens: Option<u32>,
465    ) -> Result<ChatCompletion, ChatError> {
466        let content = response
467            .choices
468            .into_iter()
469            .next()
470            .and_then(|c| c.message.content)
471            .filter(|c| !c.trim().is_empty())
472            .ok_or_else(|| {
473                AppError::Validation(crate::i18n::validation::model_no_structured_content(
474                    &self.model,
475                ))
476            })
477            .map_err(|e| {
478                ChatError::with_diagnostics(
479                    e,
480                    finish_reason.clone(),
481                    prompt_tokens,
482                    completion_tokens,
483                    AttemptOutcome::Transient,
484                )
485            })?;
486
487        // GAP-SG-10: deepseek-v4-flash:nitro and similar models do not honour
488        // `json_schema` strict mode reliably — they wrap output in markdown
489        // fences, add trailing commas, or omit quotes around keys. Try a strict
490        // parse first (zero cost for well-formed JSON), then fall back to the
491        // repair pass (a Rust port of `json_repair`) before giving up.
492        let value = crate::json_repair::repair_to_value(&content).map_err(|e| {
493            ChatError::with_diagnostics(
494                AppError::Validation(crate::i18n::validation::model_json_parse_failed(
495                    &self.model,
496                    &e,
497                )),
498                finish_reason.clone(),
499                prompt_tokens,
500                completion_tokens,
501                AttemptOutcome::Transient,
502            )
503        })?;
504
505        // GAP-SG-10: `llm_json` coerces aggressively — free text becomes a JSON
506        // string, empty input becomes `{}`, a lone delimiter becomes `null`. The
507        // enrich JUDGE contract is ALWAYS a JSON object, so a non-object result
508        // here is a malformed/refused generation, NOT a usable value. Reject it
509        // (the enrich classifier reclassifies this as a transient model hiccup,
510        // GAP-SG-09) instead of letting a coerced scalar masquerade as a
511        // valid-but-empty result downstream.
512        if !value.is_object() {
513            return Err(ChatError::with_diagnostics(
514                AppError::Validation(crate::i18n::validation::model_non_object_json(
515                    &self.model,
516                    json_shape_name(&value),
517                )),
518                finish_reason,
519                prompt_tokens,
520                completion_tokens,
521                AttemptOutcome::Transient,
522            ));
523        }
524
525        let cost = response.usage.and_then(|u| u.cost).unwrap_or(0.0);
526
527        Ok(ChatCompletion {
528            value,
529            cost_usd: cost,
530            finish_reason,
531            prompt_tokens,
532            completion_tokens,
533        })
534    }
535
536    /// Builds a `ChatRequest` for one attempt. `reasoning` is `Some` on the
537    /// primary attempt (`enabled:false`) and `None` on the mandatory-reasoning
538    /// fallback, where the field is omitted entirely.
539    fn build_request<'a>(
540        &'a self,
541        schema: serde_json::Value,
542        system_prompt: &str,
543        input_text: &str,
544        max_tokens: Option<u32>,
545        reasoning: Option<ReasoningPrefs>,
546    ) -> ChatRequest<'a> {
547        let mut messages = Vec::with_capacity(2);
548        messages.push(ChatMessage {
549            role: "system",
550            content: system_prompt.to_string(),
551        });
552        if !input_text.is_empty() {
553            messages.push(ChatMessage {
554                role: "user",
555                content: input_text.to_string(),
556            });
557        }
558        ChatRequest {
559            model: &self.model,
560            messages,
561            response_format: ResponseFormat {
562                format_type: "json_schema",
563                json_schema: JsonSchemaSpec {
564                    name: SCHEMA_NAME,
565                    strict: true,
566                    schema,
567                },
568            },
569            provider: ProviderPrefs {
570                require_parameters: true,
571            },
572            reasoning,
573            max_tokens,
574        }
575    }
576
577    /// Runs the request/retry loop, classifying every failure into a
578    /// [`ChatError`] with `retry_class` set AT THE ORIGIN (the exact HTTP
579    /// status, or the provider's structured error code) — never inferred
580    /// downstream from a formatted message (reauditor addendum to
581    /// GAP-SG-72-chat).
582    async fn execute_with_retry(
583        &self,
584        request: &ChatRequest<'_>,
585    ) -> Result<ChatResponse, ChatError> {
586        let mut last_err: Option<ChatError> = None;
587
588        for attempt in 0..crate::openrouter_http::MAX_RETRIES {
589            let result = self
590                .client
591                .post(&self.base_url)
592                .header(
593                    "Authorization",
594                    format!("Bearer {}", self.api_key.expose_secret()),
595                )
596                .json(request)
597                .send()
598                .await;
599
600            let resp = match result {
601                Ok(r) => r,
602                Err(e) if e.is_timeout() => {
603                    return Err(ChatError::new(
604                        AppError::Validation(crate::i18n::validation::openrouter_chat_timed_out()),
605                        AttemptOutcome::Transient,
606                    ));
607                }
608                Err(e) => {
609                    last_err = Some(ChatError::new(
610                        AppError::Validation(crate::i18n::validation::http_request_failed(&e)),
611                        AttemptOutcome::Transient,
612                    ));
613                    crate::openrouter_http::backoff(attempt).await;
614                    continue;
615                }
616            };
617
618            let status = resp.status();
619
620            if status.is_success() {
621                let body = resp.text().await.map_err(|e| {
622                    ChatError::new(
623                        AppError::Validation(crate::i18n::validation::failed_to_read_response_body(
624                            &e,
625                        )),
626                        AttemptOutcome::Transient,
627                    )
628                })?;
629                match serde_json::from_str::<ChatResponse>(&body) {
630                    Ok(parsed) => {
631                        // A structured error object inside a 2xx body is
632                        // classified by its own `code` (GAP-SG-03 surfaces
633                        // the real code/message instead of letting empty
634                        // choices masquerade as no-structured-content).
635                        if let Some(api_err) = parsed.error {
636                            let retry_class =
637                                crate::openrouter_http::provider_error_retry_class(&api_err);
638                            return Err(ChatError::new(
639                                AppError::ProviderError {
640                                    code: api_err.code_string(),
641                                    message: api_err.message,
642                                },
643                                retry_class,
644                            ));
645                        }
646                        return Ok(parsed);
647                    }
648                    Err(e) => {
649                        tracing::warn!(
650                            attempt,
651                            body_len = body.len(),
652                            "HTTP 200 but parse failed (retrying): {e}"
653                        );
654                        last_err = Some(ChatError::new(
655                            AppError::Validation(
656                                crate::i18n::validation::failed_to_parse_chat_response(&e),
657                            ),
658                            AttemptOutcome::Transient,
659                        ));
660                        crate::openrouter_http::backoff(attempt).await;
661                        continue;
662                    }
663                }
664            }
665
666            if status.as_u16() == 401 {
667                return Err(ChatError::new(
668                    AppError::Validation(crate::i18n::validation::openrouter_invalid_api_key_401()),
669                    AttemptOutcome::HardFailure,
670                ));
671            }
672
673            if status.as_u16() == 400 || status.as_u16() == 404 {
674                let body = resp.text().await.unwrap_or_default();
675                return Err(ChatError::new(
676                    AppError::Validation(crate::i18n::validation::openrouter_status_error(
677                        &status,
678                        &self.model,
679                        &body,
680                    )),
681                    AttemptOutcome::HardFailure,
682                ));
683            }
684
685            if status.as_u16() == 429 {
686                let retry_after = resp
687                    .headers()
688                    .get("retry-after")
689                    .and_then(|v| v.to_str().ok())
690                    .and_then(|v| v.parse::<u64>().ok())
691                    .unwrap_or(2);
692                tracing::warn!(
693                    attempt,
694                    retry_after_secs = retry_after,
695                    "OpenRouter rate limited, waiting"
696                );
697                // GAP-SG-56: surface the Retry-After delay to the caller. If
698                // every attempt is rate limited, the loop exits with this
699                // RateLimited error (retryable) carrying the server-advised
700                // wait, instead of a generic max-retries-exceeded message.
701                last_err = Some(ChatError::new(
702                    AppError::RateLimited {
703                        detail: format!("OpenRouter HTTP 429 (retry-after {retry_after}s)"),
704                    },
705                    AttemptOutcome::Transient,
706                ));
707                tokio::time::sleep(Duration::from_secs(retry_after)).await;
708                continue;
709            }
710
711            if status.is_server_error() {
712                tracing::warn!(attempt, status = %status, "OpenRouter server error, retrying");
713                last_err = Some(ChatError::new(
714                    AppError::Validation(crate::i18n::validation::openrouter_server_error(&status)),
715                    AttemptOutcome::Transient,
716                ));
717                crate::openrouter_http::backoff(attempt).await;
718                continue;
719            }
720
721            let body = resp.text().await.unwrap_or_default();
722            return Err(ChatError::new(
723                AppError::Validation(crate::i18n::validation::unexpected_http_status(
724                    &status, &body,
725                )),
726                crate::openrouter_http::status_retry_class(status),
727            ));
728        }
729
730        // GAP-SG-72-chat addendum: exhausting every retry against a
731        // transient condition (429/5xx/timeout/network) is ITSELF transient
732        // — it is exactly the case the queue's `--max-attempts` backoff
733        // covers, and must never be reclassified as a permanent failure.
734        Err(last_err.unwrap_or_else(|| {
735            ChatError::new(
736                AppError::Validation(crate::i18n::validation::openrouter_chat_max_retries()),
737                AttemptOutcome::Transient,
738            )
739        }))
740    }
741}
742
743/// Grows `current` for the next `max_tokens` retry after a truncated
744/// (`finish_reason: "length"`) response (GAP-SG-70/71). When `current` is
745/// `None` the caller left the provider default in place, so growth starts
746/// from [`crate::constants::ENRICH_INITIAL_MAX_TOKENS`] instead of an unknown
747/// base. The result is always capped at
748/// [`crate::constants::ENRICH_MAX_TOKENS_CEILING`].
749fn grow_max_tokens(current: Option<u32>) -> u32 {
750    let base = current.unwrap_or(crate::constants::ENRICH_INITIAL_MAX_TOKENS);
751    base.saturating_mul(crate::constants::ENRICH_MAX_TOKENS_GROWTH_FACTOR)
752        .min(crate::constants::ENRICH_MAX_TOKENS_CEILING)
753}
754
755/// True when an error from `execute_with_retry` indicates the model rejected
756/// `reasoning.enabled=false` because reasoning is mandatory: an HTTP 400 whose
757/// body mentions "reasoning" (case-insensitive). Triggers the one-shot retry
758/// with the `reasoning` field omitted.
759///
760/// This IS a legitimate, narrowly-scoped substring check on the underlying
761/// `AppError`'s message — not a retry-classification decision (that lives in
762/// `ChatError.retry_class`, computed at the origin). It only decides whether
763/// to attempt the mandatory-reasoning fallback shape, an orthogonal concern.
764fn reasoning_disable_rejected(err: &ChatError) -> bool {
765    let msg = err.source.to_string().to_lowercase();
766    msg.contains("400") && msg.contains("reasoning")
767}
768
769/// Names the JSON shape of `value` for diagnostics (GAP-SG-10). Used when the
770/// repaired model output is not the object the enrich JUDGE contract requires.
771fn json_shape_name(value: &serde_json::Value) -> &'static str {
772    match value {
773        serde_json::Value::Null => "null",
774        serde_json::Value::Bool(_) => "boolean",
775        serde_json::Value::Number(_) => "number",
776        serde_json::Value::String(_) => "string",
777        serde_json::Value::Array(_) => "array",
778        serde_json::Value::Object(_) => "object",
779    }
780}
781#[cfg(test)]
782#[path = "chat_api_tests.rs"]
783mod tests;