sqlite_graphrag/chat_api/
client.rs1use 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 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 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 #[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 pub fn model(&self) -> &str {
79 &self.model
80 }
81
82 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 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 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 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 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}