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