1use super::anthropic::AnthropicClient;
4use super::http::ReqwestHttpClient;
5use super::openai::OpenAiClient;
6use super::structured::NativeStructuredSupport;
7use super::types::SecretString;
8use super::zhipu::ZhipuClient;
9use super::LlmClient;
10use crate::retry::RetryConfig;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14use url::Url;
15
16#[derive(Clone, Default)]
18pub struct LlmConfig {
19 pub provider: String,
20 pub model: String,
21 pub api_key: SecretString,
22 pub base_url: Option<String>,
23 pub headers: HashMap<String, String>,
24 pub session_id_header: Option<String>,
25 pub session_id: Option<String>,
26 pub retry_config: Option<RetryConfig>,
27 pub api_timeout_ms: Option<u64>,
29 pub temperature: Option<f32>,
31 pub max_tokens: Option<usize>,
33 pub thinking_budget: Option<usize>,
35 pub logprobs: Option<bool>,
37 pub top_logprobs: Option<usize>,
39 pub disable_temperature: bool,
41 pub native_structured_support: Option<NativeStructuredSupport>,
44}
45
46impl std::fmt::Debug for LlmConfig {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("LlmConfig")
49 .field("provider", &self.provider)
50 .field("model", &self.model)
51 .field("api_key", &"[REDACTED]")
52 .field("base_url", &self.base_url)
53 .field("headers", &self.headers.keys().collect::<Vec<_>>())
54 .field("session_id_header", &self.session_id_header)
55 .field(
56 "session_id",
57 &self.session_id.as_ref().map(|_| "[REDACTED]"),
58 )
59 .field("retry_config", &self.retry_config)
60 .field("api_timeout_ms", &self.api_timeout_ms)
61 .field("temperature", &self.temperature)
62 .field("max_tokens", &self.max_tokens)
63 .field("thinking_budget", &self.thinking_budget)
64 .field("logprobs", &self.logprobs)
65 .field("top_logprobs", &self.top_logprobs)
66 .field("disable_temperature", &self.disable_temperature)
67 .field("native_structured_support", &self.native_structured_support)
68 .finish()
69 }
70}
71
72impl LlmConfig {
73 pub fn new(
74 provider: impl Into<String>,
75 model: impl Into<String>,
76 api_key: impl Into<String>,
77 ) -> Self {
78 Self {
79 provider: provider.into(),
80 model: model.into(),
81 api_key: SecretString::new(api_key.into()),
82 base_url: None,
83 headers: HashMap::new(),
84 session_id_header: None,
85 session_id: None,
86 retry_config: None,
87 api_timeout_ms: None,
88 temperature: None,
89 max_tokens: None,
90 thinking_budget: None,
91 logprobs: None,
92 top_logprobs: None,
93 disable_temperature: false,
94 native_structured_support: None,
95 }
96 }
97
98 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
99 self.base_url = Some(base_url.into());
100 self
101 }
102
103 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
104 self.headers = headers;
105 self
106 }
107
108 pub fn with_session_id_header(mut self, header_name: impl Into<String>) -> Self {
109 self.session_id_header = Some(header_name.into());
110 self
111 }
112
113 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
114 self.session_id = Some(session_id.into());
115 self
116 }
117
118 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
119 self.retry_config = Some(retry_config);
120 self
121 }
122
123 pub fn with_api_timeout(mut self, timeout_ms: u64) -> Self {
124 self.api_timeout_ms = Some(timeout_ms);
125 self
126 }
127
128 pub fn with_temperature(mut self, temperature: f32) -> Self {
129 self.temperature = Some(temperature);
130 self
131 }
132
133 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
134 self.max_tokens = Some(max_tokens);
135 self
136 }
137
138 pub fn with_thinking_budget(mut self, budget: usize) -> Self {
139 self.thinking_budget = Some(budget);
140 self
141 }
142
143 pub fn with_logprobs(mut self, enabled: bool) -> Self {
144 self.logprobs = Some(enabled);
145 self
146 }
147
148 pub fn with_top_logprobs(mut self, top_logprobs: usize) -> Self {
149 self.logprobs = Some(true);
150 self.top_logprobs = Some(top_logprobs);
151 self
152 }
153
154 pub fn with_native_structured_support(mut self, support: NativeStructuredSupport) -> Self {
155 self.native_structured_support = Some(support);
156 self
157 }
158
159 pub(crate) fn resolved_headers(&self) -> HashMap<String, String> {
160 let mut headers = self.headers.clone();
161 if let (Some(header_name), Some(session_id)) = (&self.session_id_header, &self.session_id) {
162 headers.insert(header_name.clone(), session_id.clone());
163 }
164 headers
165 }
166}
167
168fn default_openai_native_structured_support(
169 provider: &str,
170 base_url: Option<&str>,
171) -> NativeStructuredSupport {
172 let provider_is_deepseek = provider.eq_ignore_ascii_case("deepseek");
179 let url_is_deepseek = base_url.is_some_and(is_official_deepseek_url);
180 if provider_is_deepseek || url_is_deepseek {
181 return NativeStructuredSupport::JsonObject;
182 }
183
184 match base_url {
185 None => NativeStructuredSupport::JsonSchema,
186 Some(url) if url.contains("api.openai.com") => NativeStructuredSupport::JsonSchema,
187 Some(_) => NativeStructuredSupport::None,
188 }
189}
190
191fn is_official_deepseek_url(base_url: &str) -> bool {
192 Url::parse(base_url)
193 .ok()
194 .and_then(|url| url.host_str().map(str::to_owned))
195 .is_some_and(|host| host.eq_ignore_ascii_case("api.deepseek.com"))
196}
197
198pub fn create_client_with_config(config: LlmConfig) -> Arc<dyn LlmClient> {
200 let retry = config.retry_config.clone().unwrap_or_default();
201 let http = config
202 .api_timeout_ms
203 .map(|timeout_ms| {
204 ReqwestHttpClient::with_timeout(Duration::from_millis(timeout_ms))
205 .expect("failed to build LLM HTTP client with API timeout")
206 })
207 .map(|client| Arc::new(client) as Arc<dyn super::http::HttpClient>);
208 let api_key = config.api_key.expose().to_string();
209 let headers = config.resolved_headers();
210
211 match config.provider.as_str() {
212 "anthropic" | "claude" => {
213 let mut client = AnthropicClient::new(api_key, config.model)
214 .with_provider_name(config.provider.clone())
215 .with_retry_config(retry);
216 if let Some(http) = http.clone() {
217 client = client.with_http_client(http);
218 }
219 if let Some(base_url) = config.base_url {
220 client = client.with_base_url(base_url);
221 }
222 if !config.disable_temperature {
223 if let Some(temp) = config.temperature {
224 client = client.with_temperature(temp);
225 }
226 }
227 if let Some(max) = config.max_tokens {
228 client = client.with_max_tokens(max);
229 }
230 if let Some(budget) = config.thinking_budget {
231 client = client.with_thinking_budget(budget);
232 }
233 Arc::new(client)
234 }
235 "openai" | "gpt" => {
236 let native_structured_support = config.native_structured_support.unwrap_or_else(|| {
237 default_openai_native_structured_support(
238 &config.provider,
239 config.base_url.as_deref(),
240 )
241 });
242 let mut client = OpenAiClient::new(api_key, config.model)
243 .with_provider_name(config.provider.clone())
244 .with_retry_config(retry)
245 .with_native_structured_support(native_structured_support);
246 if let Some(http) = http.clone() {
247 client = client.with_http_client(http);
248 }
249 if let Some(base_url) = config.base_url {
250 client = client.with_base_url(base_url);
251 }
252 if !headers.is_empty() {
253 client = client.with_headers(headers.clone());
254 }
255 if !config.disable_temperature {
256 if let Some(temp) = config.temperature {
257 client = client.with_temperature(temp);
258 }
259 }
260 if let Some(max) = config.max_tokens {
261 client = client.with_max_tokens(max);
262 }
263 if let Some(enabled) = config.logprobs {
264 client = client.with_logprobs(enabled);
265 }
266 if let Some(top_logprobs) = config.top_logprobs {
267 client = client.with_top_logprobs(top_logprobs);
268 }
269 Arc::new(client)
270 }
271 "glm" | "zhipu" | "bigmodel" => {
272 let mut client = ZhipuClient::new(api_key, config.model).with_retry_config(retry);
273 if let Some(http) = http.clone() {
274 client = client.with_http_client(http);
275 }
276 if let Some(base_url) = config.base_url {
277 client = client.with_base_url(base_url);
278 }
279 if !config.disable_temperature {
280 if let Some(temp) = config.temperature {
281 client = client.with_temperature(temp);
282 }
283 }
284 if let Some(max) = config.max_tokens {
285 client = client.with_max_tokens(max);
286 }
287 if let Some(enabled) = config.logprobs {
288 client = client.with_logprobs(enabled);
289 }
290 if let Some(top_logprobs) = config.top_logprobs {
291 client = client.with_top_logprobs(top_logprobs);
292 }
293 Arc::new(client)
294 }
295 _ => {
297 tracing::info!(
298 "Using OpenAI-compatible client for provider '{}'",
299 config.provider
300 );
301 let native_structured_support = config.native_structured_support.unwrap_or_else(|| {
302 default_openai_native_structured_support(
303 &config.provider,
304 config.base_url.as_deref(),
305 )
306 });
307 let mut client = OpenAiClient::new(api_key, config.model)
308 .with_provider_name(config.provider.clone())
309 .with_retry_config(retry)
310 .with_native_structured_support(native_structured_support);
311 if let Some(http) = http.clone() {
312 client = client.with_http_client(http);
313 }
314 if let Some(base_url) = config.base_url {
315 client = client.with_base_url(base_url);
316 }
317 if !headers.is_empty() {
318 client = client.with_headers(headers.clone());
319 }
320 if !config.disable_temperature {
321 if let Some(temp) = config.temperature {
322 client = client.with_temperature(temp);
323 }
324 }
325 if let Some(max) = config.max_tokens {
326 client = client.with_max_tokens(max);
327 }
328 if let Some(enabled) = config.logprobs {
329 client = client.with_logprobs(enabled);
330 }
331 if let Some(top_logprobs) = config.top_logprobs {
332 client = client.with_top_logprobs(top_logprobs);
333 }
334 Arc::new(client)
335 }
336 }
337}