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 = crate::runtime_config::openrouter_chat_url(DEFAULT_OPENROUTER_CHAT_URL);
253 Self::new_with_base_url(api_key, model, timeout_secs, base_url)
254 }
255
256 /// Build a client posting to an explicit `base_url` (XDG override, tests, gateways).
257 pub fn new_with_base_url(
258 api_key: SecretBox<String>,
259 model: String,
260 timeout_secs: u64,
261 base_url: String,
262 ) -> Result<Self, AppError> {
263 let timeout_secs = if timeout_secs == 0 {
264 DEFAULT_TIMEOUT_SECS
265 } else {
266 timeout_secs
267 };
268 let client = reqwest::Client::builder()
269 .timeout(Duration::from_secs(timeout_secs))
270 .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
271 .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
272 .build()
273 .map_err(|e| {
274 AppError::Validation(crate::i18n::validation::http_client_build_failed(&e))
275 })?;
276
277 Ok(Self {
278 client,
279 api_key,
280 model,
281 base_url,
282 })
283 }
284
285 /// Test-only constructor that POSTs to an arbitrary `base_url`.
286 #[cfg(test)]
287 pub fn new_with_url(
288 api_key: SecretBox<String>,
289 model: String,
290 base_url: String,
291 timeout_secs: u64,
292 ) -> Result<Self, AppError> {
293 Self::new_with_base_url(api_key, model, timeout_secs, base_url)
294 }
295
296 /// Returns the model bound to this client.
297 pub fn model(&self) -> &str {
298 &self.model
299 }
300
301 /// Runs a single structured-output completion, transparently growing
302 /// `max_tokens` and re-issuing the request when the model truncates its
303 /// output (GAP-SG-70).
304 ///
305 /// `schema_str` is the JSON Schema (as a string) the model must honour
306 /// under `strict: true`. When `input_text` is empty only the system
307 /// message is sent. `max_tokens` seeds the first attempt; `None` lets the
308 /// provider apply its own default.
309 ///
310 /// Returns [`ChatCompletion`] on success or [`ChatError`] on failure; both
311 /// carry `finish_reason`/token diagnostics when a response was decoded.
312 ///
313 /// # Errors
314 ///
315 /// Returns [`ChatError`] when: the schema is invalid JSON; the HTTP
316 /// request fails or exhausts retries; the provider returns a permanent
317 /// error (401/400/404, or a structured `error` object in a 2xx body); the
318 /// response carries no usable content; the content cannot be parsed as
319 /// JSON even after repair; the parsed JSON is not an object; or the
320 /// response is truncated (`finish_reason: "length"`) after
321 /// [`crate::constants::ENRICH_MAX_LENGTH_RETRIES`] `max_tokens` growth
322 /// attempts are exhausted.
323 pub async fn complete(
324 &self,
325 system_prompt: &str,
326 input_text: &str,
327 schema_str: &str,
328 max_tokens: Option<u32>,
329 ) -> Result<ChatCompletion, ChatError> {
330 // A malformed schema is a permanent caller/config error — classified
331 // explicitly (no blanket `From<AppError>` conversion exists for this
332 // type; every `ChatError` states its `retry_class` at construction).
333 let schema: serde_json::Value = serde_json::from_str(schema_str).map_err(|e| {
334 ChatError::new(
335 AppError::Validation(crate::i18n::validation::invalid_json_schema_for_request(&e)),
336 AttemptOutcome::HardFailure,
337 )
338 })?;
339
340 let mut current_max_tokens = max_tokens;
341
342 for length_attempt in 0..=crate::constants::ENRICH_MAX_LENGTH_RETRIES {
343 let response = self
344 .complete_one_attempt(&schema, system_prompt, input_text, current_max_tokens)
345 .await?;
346
347 let finish_reason = response
348 .choices
349 .first()
350 .and_then(|c| c.finish_reason.clone());
351 let prompt_tokens = response.usage.as_ref().and_then(|u| u.prompt_tokens);
352 let completion_tokens = response.usage.as_ref().and_then(|u| u.completion_tokens);
353
354 let truncated = finish_reason.as_deref() == Some("length");
355 let retries_left = length_attempt < crate::constants::ENRICH_MAX_LENGTH_RETRIES;
356
357 if truncated && retries_left {
358 let next_max_tokens = grow_max_tokens(current_max_tokens);
359 tracing::warn!(
360 model = %self.model,
361 attempt = length_attempt,
362 previous_max_tokens = ?current_max_tokens,
363 next_max_tokens,
364 "OpenRouter completion truncated (finish_reason=length); \
365 retrying with a larger max_tokens budget"
366 );
367 current_max_tokens = Some(next_max_tokens);
368 continue;
369 }
370
371 if truncated {
372 tracing::warn!(
373 model = %self.model,
374 max_length_retries = crate::constants::ENRICH_MAX_LENGTH_RETRIES,
375 max_tokens = ?current_max_tokens,
376 "OpenRouter completion still truncated after exhausting \
377 max_tokens growth"
378 );
379 }
380
381 return self.finish_completion(
382 response,
383 finish_reason,
384 prompt_tokens,
385 completion_tokens,
386 );
387 }
388
389 unreachable!("loop always returns within ENRICH_MAX_LENGTH_RETRIES + 1 iterations")
390 }
391
392 /// Runs one HTTP attempt (including the mandatory-reasoning fallback) and
393 /// returns the decoded [`ChatResponse`] without inspecting `finish_reason`
394 /// or extracting content — that happens in [`Self::complete`] so the
395 /// `max_tokens` growth loop can re-issue the request first.
396 async fn complete_one_attempt(
397 &self,
398 schema: &serde_json::Value,
399 system_prompt: &str,
400 input_text: &str,
401 max_tokens: Option<u32>,
402 ) -> Result<ChatResponse, ChatError> {
403 // First attempt sends reasoning.enabled=false (token savings on the
404 // ~9 models that allow disabling). The ~4 reasoning-mandatory models
405 // (e.g. minimax-m2.7, gpt-oss-120b) reject it with HTTP 400 mentioning
406 // "reasoning"; on that specific failure we retry ONCE with the
407 // reasoning field omitted so the model uses its mandatory default. Any
408 // other error, or a second failure, propagates the original error.
409 let primary = self.build_request(
410 schema.clone(),
411 system_prompt,
412 input_text,
413 max_tokens,
414 Some(ReasoningPrefs { enabled: false }),
415 );
416 match self.execute_with_retry(&primary).await {
417 Ok(r) => Ok(r),
418 Err(first_err) => {
419 if reasoning_disable_rejected(&first_err) {
420 tracing::warn!(
421 model = %self.model,
422 "model rejected reasoning.enabled=false (mandatory); \
423 retrying once with reasoning omitted"
424 );
425 let fallback = self.build_request(
426 schema.clone(),
427 system_prompt,
428 input_text,
429 max_tokens,
430 None,
431 );
432 match self.execute_with_retry(&fallback).await {
433 Ok(r) => Ok(r),
434 Err(_) => Err(first_err),
435 }
436 } else {
437 Err(first_err)
438 }
439 }
440 }
441 }
442
443 /// Extracts content, repairs/parses it as JSON, and enforces the
444 /// object-shape guard, attaching `finish_reason`/token diagnostics to any
445 /// failure.
446 ///
447 /// Every failure branch below (missing content, JSON-repair failure,
448 /// non-object shape) classifies as `AttemptOutcome::Transient`. This is a
449 /// deliberate, acknowledged tension with `rules_rust_retry_com_backoff.md`
450 /// ("NUNCA retentar erros de parsing ou deserialização" / "NUNCA retentar
451 /// erros de deserialização"): those rules target DETERMINISTIC parse
452 /// errors, where retrying the identical input reproduces the identical
453 /// failure. Here the "input" is `deepseek-v4-flash:nitro` sampling
454 /// variance — the SAME prompt can legitimately produce well-formed JSON
455 /// on the next generation (see GAP-SG-10). So this is a typed, bounded
456 /// hiccup, not a retry-forever loophole: it is capped by `--max-attempts`
457 /// (GAP-SG-09/GAP-SG-21) and dead-letters once attempts are exhausted.
458 fn finish_completion(
459 &self,
460 response: ChatResponse,
461 finish_reason: Option<String>,
462 prompt_tokens: Option<u32>,
463 completion_tokens: Option<u32>,
464 ) -> Result<ChatCompletion, ChatError> {
465 let content = response
466 .choices
467 .into_iter()
468 .next()
469 .and_then(|c| c.message.content)
470 .filter(|c| !c.trim().is_empty())
471 .ok_or_else(|| {
472 AppError::Validation(crate::i18n::validation::model_no_structured_content(
473 &self.model,
474 ))
475 })
476 .map_err(|e| {
477 ChatError::with_diagnostics(
478 e,
479 finish_reason.clone(),
480 prompt_tokens,
481 completion_tokens,
482 AttemptOutcome::Transient,
483 )
484 })?;
485
486 // GAP-SG-10: deepseek-v4-flash:nitro and similar models do not honour
487 // `json_schema` strict mode reliably — they wrap output in markdown
488 // fences, add trailing commas, or omit quotes around keys. Try a strict
489 // parse first (zero cost for well-formed JSON), then fall back to the
490 // repair pass (a Rust port of `json_repair`) before giving up.
491 let value = crate::json_repair::repair_to_value(&content).map_err(|e| {
492 ChatError::with_diagnostics(
493 AppError::Validation(crate::i18n::validation::model_json_parse_failed(
494 &self.model,
495 &e,
496 )),
497 finish_reason.clone(),
498 prompt_tokens,
499 completion_tokens,
500 AttemptOutcome::Transient,
501 )
502 })?;
503
504 // GAP-SG-10: `llm_json` coerces aggressively — free text becomes a JSON
505 // string, empty input becomes `{}`, a lone delimiter becomes `null`. The
506 // enrich JUDGE contract is ALWAYS a JSON object, so a non-object result
507 // here is a malformed/refused generation, NOT a usable value. Reject it
508 // (the enrich classifier reclassifies this as a transient model hiccup,
509 // GAP-SG-09) instead of letting a coerced scalar masquerade as a
510 // valid-but-empty result downstream.
511 if !value.is_object() {
512 return Err(ChatError::with_diagnostics(
513 AppError::Validation(crate::i18n::validation::model_non_object_json(
514 &self.model,
515 json_shape_name(&value),
516 )),
517 finish_reason,
518 prompt_tokens,
519 completion_tokens,
520 AttemptOutcome::Transient,
521 ));
522 }
523
524 let cost = response.usage.and_then(|u| u.cost).unwrap_or(0.0);
525
526 Ok(ChatCompletion {
527 value,
528 cost_usd: cost,
529 finish_reason,
530 prompt_tokens,
531 completion_tokens,
532 })
533 }
534
535 /// Builds a `ChatRequest` for one attempt. `reasoning` is `Some` on the
536 /// primary attempt (`enabled:false`) and `None` on the mandatory-reasoning
537 /// fallback, where the field is omitted entirely.
538 fn build_request<'a>(
539 &'a self,
540 schema: serde_json::Value,
541 system_prompt: &str,
542 input_text: &str,
543 max_tokens: Option<u32>,
544 reasoning: Option<ReasoningPrefs>,
545 ) -> ChatRequest<'a> {
546 let mut messages = Vec::with_capacity(2);
547 messages.push(ChatMessage {
548 role: "system",
549 content: system_prompt.to_string(),
550 });
551 if !input_text.is_empty() {
552 messages.push(ChatMessage {
553 role: "user",
554 content: input_text.to_string(),
555 });
556 }
557 ChatRequest {
558 model: &self.model,
559 messages,
560 response_format: ResponseFormat {
561 format_type: "json_schema",
562 json_schema: JsonSchemaSpec {
563 name: SCHEMA_NAME,
564 strict: true,
565 schema,
566 },
567 },
568 provider: ProviderPrefs {
569 require_parameters: true,
570 },
571 reasoning,
572 max_tokens,
573 }
574 }
575
576 /// Runs the request/retry loop, classifying every failure into a
577 /// [`ChatError`] with `retry_class` set AT THE ORIGIN (the exact HTTP
578 /// status, or the provider's structured error code) — never inferred
579 /// downstream from a formatted message (reauditor addendum to
580 /// GAP-SG-72-chat).
581 async fn execute_with_retry(
582 &self,
583 request: &ChatRequest<'_>,
584 ) -> Result<ChatResponse, ChatError> {
585 let mut last_err: Option<ChatError> = None;
586
587 for attempt in 0..crate::openrouter_http::MAX_RETRIES {
588 let result = self
589 .client
590 .post(&self.base_url)
591 .header(
592 "Authorization",
593 format!("Bearer {}", self.api_key.expose_secret()),
594 )
595 .json(request)
596 .send()
597 .await;
598
599 let resp = match result {
600 Ok(r) => r,
601 Err(e) if e.is_timeout() => {
602 return Err(ChatError::new(
603 AppError::Validation(crate::i18n::validation::openrouter_chat_timed_out()),
604 AttemptOutcome::Transient,
605 ));
606 }
607 Err(e) => {
608 last_err = Some(ChatError::new(
609 AppError::Validation(crate::i18n::validation::http_request_failed(&e)),
610 AttemptOutcome::Transient,
611 ));
612 crate::openrouter_http::backoff(attempt).await;
613 continue;
614 }
615 };
616
617 let status = resp.status();
618
619 if status.is_success() {
620 let body = resp.text().await.map_err(|e| {
621 ChatError::new(
622 AppError::Validation(
623 crate::i18n::validation::failed_to_read_response_body(&e),
624 ),
625 AttemptOutcome::Transient,
626 )
627 })?;
628 match serde_json::from_str::<ChatResponse>(&body) {
629 Ok(parsed) => {
630 // A structured error object inside a 2xx body is
631 // classified by its own `code` (GAP-SG-03 surfaces
632 // the real code/message instead of letting empty
633 // choices masquerade as no-structured-content).
634 if let Some(api_err) = parsed.error {
635 let retry_class =
636 crate::openrouter_http::provider_error_retry_class(&api_err);
637 return Err(ChatError::new(
638 AppError::ProviderError {
639 code: api_err.code_string(),
640 message: api_err.message,
641 },
642 retry_class,
643 ));
644 }
645 return Ok(parsed);
646 }
647 Err(e) => {
648 tracing::warn!(
649 attempt,
650 body_len = body.len(),
651 "HTTP 200 but parse failed (retrying): {e}"
652 );
653 last_err = Some(ChatError::new(
654 AppError::Validation(
655 crate::i18n::validation::failed_to_parse_chat_response(&e),
656 ),
657 AttemptOutcome::Transient,
658 ));
659 crate::openrouter_http::backoff(attempt).await;
660 continue;
661 }
662 }
663 }
664
665 if status.as_u16() == 401 {
666 return Err(ChatError::new(
667 AppError::Validation(crate::i18n::validation::openrouter_invalid_api_key_401()),
668 AttemptOutcome::HardFailure,
669 ));
670 }
671
672 if status.as_u16() == 400 || status.as_u16() == 404 {
673 let body = resp.text().await.unwrap_or_default();
674 return Err(ChatError::new(
675 AppError::Validation(crate::i18n::validation::openrouter_status_error(
676 &status,
677 &self.model,
678 &body,
679 )),
680 AttemptOutcome::HardFailure,
681 ));
682 }
683
684 if status.as_u16() == 429 {
685 let retry_after = resp
686 .headers()
687 .get("retry-after")
688 .and_then(|v| v.to_str().ok())
689 .and_then(|v| v.parse::<u64>().ok())
690 .unwrap_or(2);
691 tracing::warn!(
692 attempt,
693 retry_after_secs = retry_after,
694 "OpenRouter rate limited, waiting"
695 );
696 // GAP-SG-56: surface the Retry-After delay to the caller. If
697 // every attempt is rate limited, the loop exits with this
698 // RateLimited error (retryable) carrying the server-advised
699 // wait, instead of a generic max-retries-exceeded message.
700 last_err = Some(ChatError::new(
701 AppError::RateLimited {
702 detail: format!("OpenRouter HTTP 429 (retry-after {retry_after}s)"),
703 },
704 AttemptOutcome::Transient,
705 ));
706 tokio::time::sleep(Duration::from_secs(retry_after)).await;
707 continue;
708 }
709
710 if status.is_server_error() {
711 tracing::warn!(attempt, status = %status, "OpenRouter server error, retrying");
712 last_err = Some(ChatError::new(
713 AppError::Validation(crate::i18n::validation::openrouter_server_error(&status)),
714 AttemptOutcome::Transient,
715 ));
716 crate::openrouter_http::backoff(attempt).await;
717 continue;
718 }
719
720 let body = resp.text().await.unwrap_or_default();
721 return Err(ChatError::new(
722 AppError::Validation(crate::i18n::validation::unexpected_http_status(
723 &status, &body,
724 )),
725 crate::openrouter_http::status_retry_class(status),
726 ));
727 }
728
729 // GAP-SG-72-chat addendum: exhausting every retry against a
730 // transient condition (429/5xx/timeout/network) is ITSELF transient
731 // — it is exactly the case the queue's `--max-attempts` backoff
732 // covers, and must never be reclassified as a permanent failure.
733 Err(last_err.unwrap_or_else(|| {
734 ChatError::new(
735 AppError::Validation(crate::i18n::validation::openrouter_chat_max_retries()),
736 AttemptOutcome::Transient,
737 )
738 }))
739 }
740}
741
742/// Grows `current` for the next `max_tokens` retry after a truncated
743/// (`finish_reason: "length"`) response (GAP-SG-70/71). When `current` is
744/// `None` the caller left the provider default in place, so growth starts
745/// from [`crate::constants::ENRICH_INITIAL_MAX_TOKENS`] instead of an unknown
746/// base. The result is always capped at
747/// [`crate::constants::ENRICH_MAX_TOKENS_CEILING`].
748fn grow_max_tokens(current: Option<u32>) -> u32 {
749 let base = current.unwrap_or(crate::constants::ENRICH_INITIAL_MAX_TOKENS);
750 base.saturating_mul(crate::constants::ENRICH_MAX_TOKENS_GROWTH_FACTOR)
751 .min(crate::constants::ENRICH_MAX_TOKENS_CEILING)
752}
753
754/// True when an error from `execute_with_retry` indicates the model rejected
755/// `reasoning.enabled=false` because reasoning is mandatory: an HTTP 400 whose
756/// body mentions "reasoning" (case-insensitive). Triggers the one-shot retry
757/// with the `reasoning` field omitted.
758///
759/// This IS a legitimate, narrowly-scoped substring check on the underlying
760/// `AppError`'s message — not a retry-classification decision (that lives in
761/// `ChatError.retry_class`, computed at the origin). It only decides whether
762/// to attempt the mandatory-reasoning fallback shape, an orthogonal concern.
763fn reasoning_disable_rejected(err: &ChatError) -> bool {
764 let msg = err.source.to_string().to_lowercase();
765 msg.contains("400") && msg.contains("reasoning")
766}
767
768/// Names the JSON shape of `value` for diagnostics (GAP-SG-10). Used when the
769/// repaired model output is not the object the enrich JUDGE contract requires.
770fn json_shape_name(value: &serde_json::Value) -> &'static str {
771 match value {
772 serde_json::Value::Null => "null",
773 serde_json::Value::Bool(_) => "boolean",
774 serde_json::Value::Number(_) => "number",
775 serde_json::Value::String(_) => "string",
776 serde_json::Value::Array(_) => "array",
777 serde_json::Value::Object(_) => "object",
778 }
779}
780#[cfg(test)]
781#[path = "chat_api_tests.rs"]
782mod tests;