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;