Skip to main content

sqlite_graphrag/chat_api/
client.rs

1//! Client construction and the structured-completion entry point.
2//!
3//! Owns the constructors, the `max_tokens` growth loop of
4//! [`OpenRouterChatClient::complete`], the mandatory-reasoning fallback and
5//! request assembly; the retry loop underneath lives in [`super::transport`]
6//! and response finalisation in [`super::completion`].
7
8use super::completion::{grow_max_tokens, ChatCompletion};
9use super::error::{reasoning_disable_rejected, ChatError};
10use super::wire::{
11    ChatMessage, ChatRequest, ChatResponse, JsonSchemaSpec, ProviderPrefs, ReasoningPrefs,
12    ResponseFormat,
13};
14use super::{
15    OpenRouterChatClient, DEFAULT_CONNECT_TIMEOUT_SECS, DEFAULT_TIMEOUT_SECS, SCHEMA_NAME,
16};
17use crate::constants::DEFAULT_OPENROUTER_CHAT_URL;
18use crate::errors::AppError;
19use crate::retry::AttemptOutcome;
20use secrecy::SecretBox;
21use std::time::Duration;
22
23impl OpenRouterChatClient {
24    /// Builds a chat client bound to `model`, applying `timeout_secs` as the
25    /// total per-request budget (wired from `--openrouter-timeout`). A value of
26    /// `0` falls back to `DEFAULT_TIMEOUT_SECS` so a missing or zero flag never
27    /// degrades into reqwest`'s immediate-timeout behaviour.
28    pub fn new(
29        api_key: SecretBox<String>,
30        model: String,
31        timeout_secs: u64,
32    ) -> Result<Self, AppError> {
33        let base_url = crate::runtime_config::openrouter_chat_url(DEFAULT_OPENROUTER_CHAT_URL);
34        Self::new_with_base_url(api_key, model, timeout_secs, base_url)
35    }
36
37    /// Build a client posting to an explicit `base_url` (XDG override, tests, gateways).
38    pub fn new_with_base_url(
39        api_key: SecretBox<String>,
40        model: String,
41        timeout_secs: u64,
42        base_url: String,
43    ) -> Result<Self, AppError> {
44        let timeout_secs = if timeout_secs == 0 {
45            DEFAULT_TIMEOUT_SECS
46        } else {
47            timeout_secs
48        };
49        let client = reqwest::Client::builder()
50            .timeout(Duration::from_secs(timeout_secs))
51            .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
52            .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
53            .build()
54            .map_err(|e| {
55                AppError::Validation(crate::i18n::validation::http_client_build_failed(&e))
56            })?;
57
58        Ok(Self {
59            client,
60            api_key,
61            model,
62            base_url,
63        })
64    }
65
66    /// Test-only constructor that POSTs to an arbitrary `base_url`.
67    #[cfg(test)]
68    pub fn new_with_url(
69        api_key: SecretBox<String>,
70        model: String,
71        base_url: String,
72        timeout_secs: u64,
73    ) -> Result<Self, AppError> {
74        Self::new_with_base_url(api_key, model, timeout_secs, base_url)
75    }
76
77    /// Returns the model bound to this client.
78    pub fn model(&self) -> &str {
79        &self.model
80    }
81
82    /// Runs a single structured-output completion, transparently growing
83    /// `max_tokens` and re-issuing the request when the model truncates its
84    /// output (GAP-SG-70).
85    ///
86    /// `schema_str` is the JSON Schema (as a string) the model must honour
87    /// under `strict: true`. When `input_text` is empty only the system
88    /// message is sent. `max_tokens` seeds the first attempt; `None` lets the
89    /// provider apply its own default.
90    ///
91    /// Returns [`ChatCompletion`] on success or [`ChatError`] on failure; both
92    /// carry `finish_reason`/token diagnostics when a response was decoded.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`ChatError`] when: the schema is invalid JSON; the HTTP
97    /// request fails or exhausts retries; the provider returns a permanent
98    /// error (401/400/404, or a structured `error` object in a 2xx body); the
99    /// response carries no usable content; the content cannot be parsed as
100    /// JSON even after repair; the parsed JSON is not an object; or the
101    /// response is truncated (`finish_reason: "length"`) after
102    /// [`crate::constants::ENRICH_MAX_LENGTH_RETRIES`] `max_tokens` growth
103    /// attempts are exhausted.
104    pub async fn complete(
105        &self,
106        system_prompt: &str,
107        input_text: &str,
108        schema_str: &str,
109        max_tokens: Option<u32>,
110    ) -> Result<ChatCompletion, ChatError> {
111        // A malformed schema is a permanent caller/config error — classified
112        // explicitly (no blanket `From<AppError>` conversion exists for this
113        // type; every `ChatError` states its `retry_class` at construction).
114        let schema: serde_json::Value = serde_json::from_str(schema_str).map_err(|e| {
115            ChatError::new(
116                AppError::Validation(crate::i18n::validation::invalid_json_schema_for_request(&e)),
117                AttemptOutcome::HardFailure,
118            )
119        })?;
120
121        let mut current_max_tokens = max_tokens;
122
123        for length_attempt in 0..=crate::constants::ENRICH_MAX_LENGTH_RETRIES {
124            let response = self
125                .complete_one_attempt(&schema, system_prompt, input_text, current_max_tokens)
126                .await?;
127
128            let finish_reason = response
129                .choices
130                .first()
131                .and_then(|c| c.finish_reason.clone());
132            let prompt_tokens = response.usage.as_ref().and_then(|u| u.prompt_tokens);
133            let completion_tokens = response.usage.as_ref().and_then(|u| u.completion_tokens);
134
135            let truncated = finish_reason.as_deref() == Some("length");
136            let retries_left = length_attempt < crate::constants::ENRICH_MAX_LENGTH_RETRIES;
137
138            if truncated && retries_left {
139                let next_max_tokens = grow_max_tokens(current_max_tokens);
140                tracing::warn!(
141                    model = %self.model,
142                    attempt = length_attempt,
143                    previous_max_tokens = ?current_max_tokens,
144                    next_max_tokens,
145                    "OpenRouter completion truncated (finish_reason=length); \
146                     retrying with a larger max_tokens budget"
147                );
148                current_max_tokens = Some(next_max_tokens);
149                continue;
150            }
151
152            if truncated {
153                tracing::warn!(
154                    model = %self.model,
155                    max_length_retries = crate::constants::ENRICH_MAX_LENGTH_RETRIES,
156                    max_tokens = ?current_max_tokens,
157                    "OpenRouter completion still truncated after exhausting \
158                     max_tokens growth"
159                );
160            }
161
162            return self.finish_completion(
163                response,
164                finish_reason,
165                prompt_tokens,
166                completion_tokens,
167            );
168        }
169
170        unreachable!("loop always returns within ENRICH_MAX_LENGTH_RETRIES + 1 iterations")
171    }
172
173    /// Runs one HTTP attempt (including the mandatory-reasoning fallback) and
174    /// returns the decoded [`ChatResponse`] without inspecting `finish_reason`
175    /// or extracting content — that happens in [`Self::complete`] so the
176    /// `max_tokens` growth loop can re-issue the request first.
177    async fn complete_one_attempt(
178        &self,
179        schema: &serde_json::Value,
180        system_prompt: &str,
181        input_text: &str,
182        max_tokens: Option<u32>,
183    ) -> Result<ChatResponse, ChatError> {
184        // First attempt sends reasoning.enabled=false (token savings on the
185        // ~9 models that allow disabling). The ~4 reasoning-mandatory models
186        // (e.g. minimax-m2.7, gpt-oss-120b) reject it with HTTP 400 mentioning
187        // "reasoning"; on that specific failure we retry ONCE with the
188        // reasoning field omitted so the model uses its mandatory default. Any
189        // other error, or a second failure, propagates the original error.
190        let primary = self.build_request(
191            schema.clone(),
192            system_prompt,
193            input_text,
194            max_tokens,
195            Some(ReasoningPrefs { enabled: false }),
196        );
197        match self.execute_with_retry(&primary).await {
198            Ok(r) => Ok(r),
199            Err(first_err) => {
200                if reasoning_disable_rejected(&first_err) {
201                    tracing::warn!(
202                        model = %self.model,
203                        "model rejected reasoning.enabled=false (mandatory); \
204                         retrying once with reasoning omitted"
205                    );
206                    let fallback = self.build_request(
207                        schema.clone(),
208                        system_prompt,
209                        input_text,
210                        max_tokens,
211                        None,
212                    );
213                    match self.execute_with_retry(&fallback).await {
214                        Ok(r) => Ok(r),
215                        Err(_) => Err(first_err),
216                    }
217                } else {
218                    Err(first_err)
219                }
220            }
221        }
222    }
223
224    /// Builds a `ChatRequest` for one attempt. `reasoning` is `Some` on the
225    /// primary attempt (`enabled:false`) and `None` on the mandatory-reasoning
226    /// fallback, where the field is omitted entirely.
227    fn build_request<'a>(
228        &'a self,
229        schema: serde_json::Value,
230        system_prompt: &str,
231        input_text: &str,
232        max_tokens: Option<u32>,
233        reasoning: Option<ReasoningPrefs>,
234    ) -> ChatRequest<'a> {
235        let mut messages = Vec::with_capacity(2);
236        messages.push(ChatMessage {
237            role: "system",
238            content: system_prompt.to_string(),
239        });
240        if !input_text.is_empty() {
241            messages.push(ChatMessage {
242                role: "user",
243                content: input_text.to_string(),
244            });
245        }
246        ChatRequest {
247            model: &self.model,
248            messages,
249            response_format: ResponseFormat {
250                format_type: "json_schema",
251                json_schema: JsonSchemaSpec {
252                    name: SCHEMA_NAME,
253                    strict: true,
254                    schema,
255                },
256            },
257            provider: ProviderPrefs {
258                require_parameters: true,
259            },
260            reasoning,
261            max_tokens,
262        }
263    }
264}