1pub mod bedrock;
4pub mod claude;
5pub mod claude_cli;
6pub mod openai;
7
8use std::future::Future;
9use std::pin::Pin;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result};
13use reqwest::Client;
14use serde_json::Value;
15
16use crate::claude::error::ClaudeError;
17use crate::claude::model_config::get_model_registry;
18use crate::request_log;
19
20pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
25
26#[derive(Clone, Debug)]
28pub struct AiClientMetadata {
29 pub provider: String,
31 pub model: String,
33 pub max_context_length: usize,
35 pub max_response_length: usize,
37 pub active_beta: Option<(String, String)>,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum PromptStyle {
48 Claude,
50 OpenAi,
52}
53
54impl AiClientMetadata {
55 #[must_use]
63 pub fn prompt_style(&self) -> PromptStyle {
64 match self.provider.as_str() {
65 "OpenAI" | "Ollama" => PromptStyle::OpenAi,
66 _ => PromptStyle::Claude,
67 }
68 }
69}
70
71pub(crate) fn build_http_client() -> Result<Client> {
75 Client::builder()
76 .timeout(REQUEST_TIMEOUT)
77 .build()
78 .context("Failed to build HTTP client")
79}
80
81pub(crate) fn record_ai_http(
84 url: &str,
85 started: Instant,
86 result: &reqwest::Result<reqwest::Response>,
87) {
88 match result {
89 Ok(r) => {
90 request_log::record_http(
91 "claude",
92 "POST",
93 url,
94 started,
95 Some(r.status().as_u16()),
96 None,
97 );
98 }
99 Err(e) => {
100 request_log::record_http("claude", "POST", url, started, None, Some(&e.to_string()));
101 }
102 }
103}
104
105#[must_use]
108pub(crate) fn registry_max_output_tokens(
109 model: &str,
110 active_beta: &Option<(String, String)>,
111) -> i32 {
112 let registry = get_model_registry();
113 if let Some((_, value)) = active_beta {
114 registry.get_max_output_tokens_with_beta(model, value) as i32
115 } else {
116 registry.get_max_output_tokens(model) as i32
117 }
118}
119
120#[must_use]
123pub(crate) fn registry_model_limits(
124 model: &str,
125 active_beta: &Option<(String, String)>,
126) -> (usize, usize) {
127 let registry = get_model_registry();
128 match active_beta {
129 Some((_, value)) => (
130 registry.get_input_context_with_beta(model, value),
131 registry.get_max_output_tokens_with_beta(model, value),
132 ),
133 None => (
134 registry.get_input_context(model),
135 registry.get_max_output_tokens(model),
136 ),
137 }
138}
139
140pub(crate) async fn check_error_response(response: reqwest::Response) -> Result<reqwest::Response> {
147 if response.status().is_success() {
148 return Ok(response);
149 }
150 let status = response.status();
151 let error_text = response.text().await.unwrap_or_else(|e| {
152 tracing::debug!("Failed to read error response body: {e}");
153 String::new()
154 });
155 Err(ClaudeError::ApiRequestFailed(format!("HTTP {status}: {error_text}")).into())
156}
157
158pub(crate) fn log_response_success(provider: &str, result: &Result<String>) {
160 if let Ok(text) = result {
161 tracing::debug!(
162 response_len = text.len(),
163 "Successfully extracted text content from {} API response",
164 provider
165 );
166 tracing::debug!(
167 response_content = %text,
168 "{} API response content",
169 provider
170 );
171 }
172}
173
174#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
182pub struct AiClientCapabilities {
183 pub supports_response_schema: bool,
190}
191
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
198pub enum ResponseFormat {
199 #[default]
202 Yaml,
203 JsonSchema,
208}
209
210impl ResponseFormat {
211 #[must_use]
214 pub fn from_capabilities(caps: &AiClientCapabilities) -> Self {
215 if caps.supports_response_schema {
216 Self::JsonSchema
217 } else {
218 Self::Yaml
219 }
220 }
221}
222
223#[derive(Clone, Debug, Default)]
230pub struct RequestOptions {
231 pub response_schema: Option<Value>,
235}
236
237impl RequestOptions {
238 #[must_use]
240 pub fn with_response_schema(mut self, schema: Value) -> Self {
241 self.response_schema = Some(schema);
242 self
243 }
244}
245
246pub trait AiClient: Send + Sync {
248 fn send_request<'a>(
250 &'a self,
251 system_prompt: &'a str,
252 user_prompt: &'a str,
253 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>;
254
255 fn get_metadata(&self) -> AiClientMetadata;
257
258 fn capabilities(&self) -> AiClientCapabilities {
265 AiClientCapabilities::default()
266 }
267
268 fn send_request_with_options<'a>(
277 &'a self,
278 system_prompt: &'a str,
279 user_prompt: &'a str,
280 _options: RequestOptions,
281 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
282 self.send_request(system_prompt, user_prompt)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn meta(provider: &str) -> AiClientMetadata {
291 AiClientMetadata {
292 provider: provider.to_string(),
293 model: "test-model".to_string(),
294 max_context_length: 1024,
295 max_response_length: 1024,
296 active_beta: None,
297 }
298 }
299
300 #[test]
301 fn prompt_style_openai() {
302 assert_eq!(meta("OpenAI").prompt_style(), PromptStyle::OpenAi);
303 }
304
305 #[test]
306 fn prompt_style_ollama() {
307 assert_eq!(meta("Ollama").prompt_style(), PromptStyle::OpenAi);
308 }
309
310 #[test]
311 fn prompt_style_anthropic() {
312 assert_eq!(meta("Anthropic").prompt_style(), PromptStyle::Claude);
313 }
314
315 #[test]
316 fn prompt_style_bedrock() {
317 assert_eq!(
318 meta("Anthropic Bedrock").prompt_style(),
319 PromptStyle::Claude
320 );
321 }
322
323 #[test]
324 fn prompt_style_unknown_defaults_to_claude() {
325 assert_eq!(meta("SomeNewProvider").prompt_style(), PromptStyle::Claude);
326 }
327
328 #[test]
331 fn prompt_style_case_sensitive() {
332 assert_eq!(meta("openai").prompt_style(), PromptStyle::Claude);
333 assert_eq!(meta("ollama").prompt_style(), PromptStyle::Claude);
334 }
335
336 #[test]
337 fn capabilities_default_is_all_disabled() {
338 let caps = AiClientCapabilities::default();
339 assert!(!caps.supports_response_schema);
340 }
341
342 #[test]
343 fn response_format_default_is_yaml() {
344 assert_eq!(ResponseFormat::default(), ResponseFormat::Yaml);
345 }
346
347 #[test]
348 fn response_format_from_capabilities_disabled_picks_yaml() {
349 let caps = AiClientCapabilities::default();
350 assert_eq!(
351 ResponseFormat::from_capabilities(&caps),
352 ResponseFormat::Yaml
353 );
354 }
355
356 #[test]
357 fn response_format_from_capabilities_enabled_picks_json_schema() {
358 let caps = AiClientCapabilities {
359 supports_response_schema: true,
360 };
361 assert_eq!(
362 ResponseFormat::from_capabilities(&caps),
363 ResponseFormat::JsonSchema
364 );
365 }
366
367 #[test]
368 fn request_options_with_response_schema_sets_field() {
369 let value = serde_json::json!({"type": "object"});
370 let opts = RequestOptions::default().with_response_schema(value.clone());
371 assert_eq!(opts.response_schema, Some(value));
372 }
373
374 #[test]
375 fn request_options_default_has_no_schema() {
376 let opts = RequestOptions::default();
377 assert!(opts.response_schema.is_none());
378 }
379}