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,
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 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 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 #[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 pub fn model(&self) -> &str {
80 &self.model
81 }
82
83 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 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 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 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 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}