Skip to main content

a3s_code_core/llm/
factory.rs

1//! LLM client factory
2
3use 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/// LLM client configuration
17#[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    /// Per-model API HTTP timeout in milliseconds. None means no HTTP timeout.
28    pub api_timeout_ms: Option<u64>,
29    /// Sampling temperature (0.0–1.0). None uses the provider default.
30    pub temperature: Option<f32>,
31    /// Maximum tokens to generate. None uses the client default.
32    pub max_tokens: Option<usize>,
33    /// Extended thinking budget in tokens (Anthropic only).
34    pub thinking_budget: Option<usize>,
35    /// Request token-level log probabilities from OpenAI-compatible providers.
36    pub logprobs: Option<bool>,
37    /// Number of alternative logprobs per token when logprobs are requested.
38    pub top_logprobs: Option<usize>,
39    /// When true, temperature is never sent to the API (e.g., o1 models).
40    pub disable_temperature: bool,
41    /// Explicit native structured-output support. None means infer from provider
42    /// and base URL; custom OpenAI-compatible endpoints default to prompt mode.
43    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    // DeepSeek's reasoning endpoints accept `response_format: json_object`,
173    // but reject a forced function `tool_choice` while thinking is enabled.
174    // Advertise the narrower capability so structured generation selects the
175    // safe JSON-object route instead of sending a request that always returns
176    // HTTP 400.  Match the hostname as well for callers that label the
177    // provider "openai" while pointing at the official DeepSeek endpoint.
178    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
198/// Create LLM client with full configuration (supports custom base_url)
199pub 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        // OpenAI-compatible providers (deepseek, groq, together, ollama, etc.)
296        _ => {
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}