koda_core/providers/
mod.rs1pub mod anthropic;
7pub mod gemini;
9pub mod openai_compat;
11pub mod stream_tag_filter;
13pub mod think_tag_filter;
15
16pub mod mock;
18
19use anyhow::Result;
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ToolCall {
26 pub id: String,
28 pub function_name: String,
30 pub arguments: String,
32 #[serde(skip_serializing_if = "Option::is_none", default)]
34 pub thought_signature: Option<String>,
35}
36
37#[derive(Debug, Clone, Default)]
39pub struct TokenUsage {
40 pub prompt_tokens: i64,
42 pub completion_tokens: i64,
44 pub cache_read_tokens: i64,
46 pub cache_creation_tokens: i64,
48 pub thinking_tokens: i64,
50 pub stop_reason: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct LlmResponse {
58 pub content: Option<String>,
60 pub tool_calls: Vec<ToolCall>,
62 pub usage: TokenUsage,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ImageData {
69 pub media_type: String,
71 pub base64: String,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ChatMessage {
78 pub role: String,
80 pub content: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub tool_calls: Option<Vec<ToolCall>>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub tool_call_id: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none", default)]
90 pub images: Option<Vec<ImageData>>,
91}
92
93impl ChatMessage {
94 pub fn text(role: &str, content: &str) -> Self {
96 Self {
97 role: role.to_string(),
98 content: Some(content.to_string()),
99 tool_calls: None,
100 tool_call_id: None,
101 images: None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolDefinition {
109 pub name: String,
111 pub description: String,
113 pub parameters: serde_json::Value,
115}
116
117#[derive(Debug, Clone)]
119pub struct ModelInfo {
120 pub id: String,
122 #[allow(dead_code)]
124 pub owned_by: Option<String>,
125}
126
127#[derive(Debug, Clone, Default)]
129pub struct ModelCapabilities {
130 pub context_window: Option<usize>,
132 pub max_output_tokens: Option<usize>,
134}
135
136fn is_localhost_url(url: &str) -> bool {
138 let lower = url.to_lowercase();
139 lower.contains("://localhost") || lower.contains("://127.0.0.1") || lower.contains("://[::1]")
140}
141
142pub fn build_http_client(base_url: Option<&str>) -> reqwest::Client {
149 let mut builder = reqwest::Client::builder();
150
151 let proxy_url = crate::runtime_env::get("HTTPS_PROXY")
152 .or_else(|| crate::runtime_env::get("HTTP_PROXY"))
153 .or_else(|| crate::runtime_env::get("https_proxy"))
154 .or_else(|| crate::runtime_env::get("http_proxy"));
155
156 if let Some(ref url) = proxy_url
157 && !url.is_empty()
158 {
159 match reqwest::Proxy::all(url) {
160 Ok(mut proxy) => {
161 proxy = proxy.no_proxy(reqwest::NoProxy::from_string("localhost,127.0.0.1,::1"));
163
164 if !url.contains('@') {
166 let user = crate::runtime_env::get("PROXY_USER");
167 let pass = crate::runtime_env::get("PROXY_PASS");
168 if let (Some(u), Some(p)) = (user, pass) {
169 proxy = proxy.basic_auth(&u, &p);
170 tracing::debug!("Using proxy with basic auth (credentials redacted)");
171 }
172 }
173
174 builder = builder.proxy(proxy);
175 tracing::debug!("Using proxy: {}", redact_url_credentials(url));
176 }
177 Err(e) => {
178 tracing::warn!("Invalid proxy URL '{}': {e}", redact_url_credentials(url));
179 }
180 }
181 }
182
183 let wants_skip_tls = crate::runtime_env::get("KODA_ACCEPT_INVALID_CERTS")
186 .map(|v| v == "1" || v == "true")
187 .unwrap_or(false);
188 let is_local = base_url.is_some_and(is_localhost_url);
189 if wants_skip_tls && is_local {
190 tracing::info!("TLS certificate validation disabled for local provider.");
191 builder = builder.danger_accept_invalid_certs(true);
192 } else if wants_skip_tls {
193 tracing::warn!(
194 "KODA_ACCEPT_INVALID_CERTS is set but provider URL is not localhost — ignoring. \
195 TLS bypass is only allowed for local providers (localhost/127.0.0.1)."
196 );
197 }
198
199 builder.build().unwrap_or_else(|_| reqwest::Client::new())
200}
201
202fn redact_url_credentials(url: &str) -> String {
206 if let Some(at_pos) = url.find('@')
208 && let Some(scheme_end) = url.find("://")
209 {
210 let prefix = &url[..scheme_end + 3]; let host_part = &url[at_pos..]; return format!("{prefix}***:***{host_part}");
213 }
214 url.to_string()
215}
216
217#[derive(Debug, Clone)]
219pub enum StreamChunk {
220 TextDelta(String),
222 ThinkingDelta(String),
224 ToolCalls(Vec<ToolCall>),
226 Done(TokenUsage),
228}
229
230#[async_trait]
232pub trait LlmProvider: Send + Sync {
233 async fn chat(
235 &self,
236 messages: &[ChatMessage],
237 tools: &[ToolDefinition],
238 settings: &crate::config::ModelSettings,
239 ) -> Result<LlmResponse>;
240
241 async fn chat_stream(
244 &self,
245 messages: &[ChatMessage],
246 tools: &[ToolDefinition],
247 settings: &crate::config::ModelSettings,
248 ) -> Result<tokio::sync::mpsc::Receiver<StreamChunk>>;
249
250 async fn list_models(&self) -> Result<Vec<ModelInfo>>;
252
253 async fn model_capabilities(&self, _model: &str) -> Result<ModelCapabilities> {
259 Ok(ModelCapabilities::default())
260 }
261
262 fn provider_name(&self) -> &str;
264}
265
266use crate::config::{KodaConfig, ProviderType};
269
270pub fn create_provider(config: &KodaConfig) -> Box<dyn LlmProvider> {
272 let api_key = crate::runtime_env::get(config.provider_type.env_key_name());
273 match config.provider_type {
274 ProviderType::Anthropic => {
275 let key = api_key.unwrap_or_else(|| {
276 tracing::warn!("No ANTHROPIC_API_KEY set");
277 String::new()
278 });
279 Box::new(anthropic::AnthropicProvider::new(
280 key,
281 Some(&config.base_url),
282 ))
283 }
284 ProviderType::Gemini => {
285 let key = api_key.unwrap_or_else(|| {
286 tracing::warn!("No GEMINI_API_KEY set");
287 String::new()
288 });
289 Box::new(gemini::GeminiProvider::new(key, Some(&config.base_url)))
290 }
291 ProviderType::Mock => Box::new(mock::MockProvider::from_env()),
292 _ => Box::new(openai_compat::OpenAiCompatProvider::new(
293 &config.base_url,
294 api_key,
295 )),
296 }
297}