omni_dev/claude/ai.rs
1//! AI client trait and metadata definitions.
2
3pub 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
20/// HTTP request timeout for AI API calls.
21///
22/// Set to 5 minutes to accommodate large prompts and long model responses
23/// (up to 64k output tokens) while preventing indefinite hangs.
24pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
25
26/// Metadata about an AI client implementation.
27#[derive(Clone, Debug)]
28pub struct AiClientMetadata {
29 /// Service provider name.
30 pub provider: String,
31 /// Model identifier.
32 pub model: String,
33 /// Maximum context length supported.
34 pub max_context_length: usize,
35 /// Maximum token response length supported.
36 pub max_response_length: usize,
37 /// Active beta header, if any: (key, value).
38 pub active_beta: Option<(String, String)>,
39}
40
41/// Prompt formatting families for AI providers.
42///
43/// Determines provider-specific prompt behaviour (e.g., how template
44/// instructions are phrased). Parse once at the boundary via
45/// [`AiClientMetadata::prompt_style`] and match on the enum downstream.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum PromptStyle {
48 /// Claude models handle "literal template" instructions correctly.
49 Claude,
50 /// OpenAI-compatible models (OpenAI, Ollama) need different formatting.
51 OpenAi,
52}
53
54impl AiClientMetadata {
55 /// Derives the prompt style from the provider name.
56 ///
57 /// Matches against the exact strings set by each [`AiClient`] implementation:
58 /// - `"OpenAI"` and `"Ollama"` → [`PromptStyle::OpenAi`]
59 /// - `"Anthropic"` and `"Anthropic Bedrock"` → [`PromptStyle::Claude`]
60 ///
61 /// Unrecognised provider strings default to [`PromptStyle::Claude`].
62 #[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
71// ── Shared helpers for AI client implementations ────────────────────
72
73/// Builds an HTTP client with the standard request timeout.
74pub(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
81/// Appends a best-effort HTTP record for one AI-backend request attempt. The
82/// `service` tag distinguishes the backend that issued it (`anthropic`,
83/// `bedrock`, `openai`, or `ollama`) so their traffic is filterable apart;
84/// `method` covers both the chat `POST` and the metadata probes (GET/POST).
85pub(crate) fn record_ai_http(
86 service: &str,
87 method: &str,
88 url: &str,
89 started: Instant,
90 result: &reqwest::Result<reqwest::Response>,
91) {
92 request_log::record_http_result(service, method, url, started, result);
93}
94
95/// Returns the maximum output tokens for a model from the registry,
96/// respecting beta overrides.
97#[must_use]
98pub(crate) fn registry_max_output_tokens(
99 model: &str,
100 active_beta: &Option<(String, String)>,
101) -> i32 {
102 let registry = get_model_registry();
103 if let Some((_, value)) = active_beta {
104 registry.get_max_output_tokens_with_beta(model, value) as i32
105 } else {
106 registry.get_max_output_tokens(model) as i32
107 }
108}
109
110/// Returns the (input context length, max response length) for a model
111/// from the registry, respecting beta overrides.
112#[must_use]
113pub(crate) fn registry_model_limits(
114 model: &str,
115 active_beta: &Option<(String, String)>,
116) -> (usize, usize) {
117 let registry = get_model_registry();
118 match active_beta {
119 Some((_, value)) => (
120 registry.get_input_context_with_beta(model, value),
121 registry.get_max_output_tokens_with_beta(model, value),
122 ),
123 None => (
124 registry.get_input_context(model),
125 registry.get_max_output_tokens(model),
126 ),
127 }
128}
129
130/// Returns the `(input, output)` USD prices *per million tokens* for a model
131/// from the registry, or `None` when the model is unknown or unpriced.
132///
133/// Backed by [`crate::claude::model_config::ModelSpec::input_token_price`] /
134/// [`output_token_price`](crate::claude::model_config::ModelSpec::output_token_price),
135/// so it inherits the same identifier normalization as the other registry
136/// lookups (Bedrock/region prefixes, version suffixes). Both prices must be
137/// present for a `Some` result — a half-priced entry is treated as unpriced.
138#[must_use]
139pub(crate) fn registry_token_prices(model: &str) -> Option<(f64, f64)> {
140 let registry = get_model_registry();
141 let spec = registry.get_model_spec(model)?;
142 match (spec.input_token_price, spec.output_token_price) {
143 (Some(input), Some(output)) => Some((input, output)),
144 _ => None,
145 }
146}
147
148/// Computes USD cost from token counts and *per-million-token* prices.
149///
150/// Pure arithmetic split out from [`compute_cost_usd`] so it can be tested
151/// against a fixture price table independent of the model registry.
152#[must_use]
153pub(crate) fn cost_from_prices(
154 input_tokens: u64,
155 output_tokens: u64,
156 input_price: f64,
157 output_price: f64,
158) -> f64 {
159 (output_tokens as f64 / 1_000_000.0).mul_add(
160 output_price,
161 (input_tokens as f64 / 1_000_000.0) * input_price,
162 )
163}
164
165/// Computes the USD cost of an invocation from token counts and the model's
166/// registry prices, or `None` (with a `warn`) when the model is unpriced so
167/// unpriced usage is noticed rather than silently zeroed.
168#[must_use]
169pub(crate) fn compute_cost_usd(model: &str, input_tokens: u64, output_tokens: u64) -> Option<f64> {
170 let Some((input_price, output_price)) = registry_token_prices(model) else {
171 tracing::warn!(
172 model = %model,
173 "no price table entry for model; cost_usd will be reported as unknown"
174 );
175 return None;
176 };
177 Some(cost_from_prices(
178 input_tokens,
179 output_tokens,
180 input_price,
181 output_price,
182 ))
183}
184
185/// Checks an HTTP response for error status and returns a structured error
186/// if non-success.
187///
188/// On success, returns the response unchanged for further processing.
189/// On failure, reads the error body and returns a
190/// [`ClaudeError::ApiRequestFailed`].
191pub(crate) async fn check_error_response(response: reqwest::Response) -> Result<reqwest::Response> {
192 if response.status().is_success() {
193 return Ok(response);
194 }
195 let status = response.status();
196 let error_text = response.text().await.unwrap_or_else(|e| {
197 tracing::debug!("Failed to read error response body: {e}");
198 String::new()
199 });
200 Err(ClaudeError::ApiRequestFailed(format!("HTTP {status}: {error_text}")).into())
201}
202
203/// Logs successful text extraction from an AI API response.
204pub(crate) fn log_response_success(provider: &str, result: &Result<String>) {
205 if let Ok(text) = result {
206 tracing::debug!(
207 response_len = text.len(),
208 "Successfully extracted text content from {} API response",
209 provider
210 );
211 tracing::debug!(
212 response_content = %text,
213 "{} API response content",
214 provider
215 );
216 }
217}
218
219/// Capabilities advertised by an [`AiClient`] implementation.
220///
221/// Used by call sites to decide whether to attach a structured-response
222/// schema (or other backend-specific request options) before dispatching.
223/// The default value is the conservative ''nothing supported'' baseline so
224/// new fields can be added without forcing existing implementations to
225/// update.
226#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
227pub struct AiClientCapabilities {
228 /// Whether the backend can enforce a JSON Schema on its response.
229 ///
230 /// When `true`, the call site may set
231 /// [`RequestOptions::response_schema`]; the backend will hand the schema
232 /// to its underlying API (e.g. `claude -p --json-schema <file>`) and the
233 /// API re-prompts until the model produces a validating response.
234 pub supports_response_schema: bool,
235}
236
237/// Whether the response should be formatted as YAML (default) or JSON
238/// matching a schema.
239///
240/// Used by the prompts module to swap the format-specific portion of a
241/// structured prompt without rewriting the semantic instructions.
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
243pub enum ResponseFormat {
244 /// Plain YAML, with the prompt asking the model to emit a fenced or
245 /// bare YAML document.
246 #[default]
247 Yaml,
248 /// JSON object that matches a schema attached via
249 /// [`RequestOptions::response_schema`]. The prompt drops the YAML
250 /// structure literal and tells the model to return only the JSON
251 /// object.
252 JsonSchema,
253}
254
255impl ResponseFormat {
256 /// Returns the response format that should be used given a backend's
257 /// capabilities.
258 #[must_use]
259 pub fn from_capabilities(caps: &AiClientCapabilities) -> Self {
260 if caps.supports_response_schema {
261 Self::JsonSchema
262 } else {
263 Self::Yaml
264 }
265 }
266}
267
268/// Per-request options passed to [`AiClient::send_request_with_options`].
269///
270/// Schema and other knobs live on the request, not the client, so a shared
271/// client cannot leak settings between concurrent calls. Backends that do
272/// not support an option are expected to ignore it (and the call site is
273/// expected to consult [`AiClient::capabilities`] before setting it).
274#[derive(Clone, Debug, Default)]
275pub struct RequestOptions {
276 /// Optional JSON Schema (as a `serde_json::Value`) constraining the
277 /// model's response. Only honoured by backends whose
278 /// [`AiClientCapabilities::supports_response_schema`] is `true`.
279 pub response_schema: Option<Value>,
280}
281
282impl RequestOptions {
283 /// Returns a new [`RequestOptions`] with [`Self::response_schema`] set.
284 #[must_use]
285 pub fn with_response_schema(mut self, schema: Value) -> Self {
286 self.response_schema = Some(schema);
287 self
288 }
289}
290
291/// Per-invocation telemetry returned alongside an AI response.
292///
293/// Decouples call telemetry from the response payload so more fields (token
294/// counts, cache hits, …) can be added without touching every backend's
295/// return type. `cost_usd` is `None` when the backend cannot determine a
296/// price (missing usage data, an unpriced model, or a backend that does not
297/// yet populate it) — callers fall back to reporting the cost as unknown.
298#[derive(Clone, Debug, Default, PartialEq)]
299pub struct InvocationMetrics {
300 /// Total billed cost of the invocation in USD, if known.
301 pub cost_usd: Option<f64>,
302}
303
304/// An AI response paired with its per-invocation [`InvocationMetrics`].
305///
306/// Returned by [`AiClient::send_request_with_metrics`]. The plain
307/// [`send_request`](AiClient::send_request) /
308/// [`send_request_with_options`](AiClient::send_request_with_options) paths
309/// keep returning a bare `String`, so existing call sites are unaffected;
310/// telemetry-aware callers opt in via the metrics method.
311#[derive(Clone, Debug)]
312pub struct AiResponse {
313 /// The model's text response.
314 pub text: String,
315 /// Per-invocation telemetry (cost, …).
316 pub metrics: InvocationMetrics,
317}
318
319/// Trait for AI service clients.
320pub trait AiClient: Send + Sync {
321 /// Sends a request to the AI service and returns the raw response.
322 fn send_request<'a>(
323 &'a self,
324 system_prompt: &'a str,
325 user_prompt: &'a str,
326 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>;
327
328 /// Returns metadata about the AI client implementation.
329 fn get_metadata(&self) -> AiClientMetadata;
330
331 /// Returns the optional capabilities advertised by this backend.
332 ///
333 /// The default implementation returns the all-disabled baseline so
334 /// existing backends remain source-compatible. Backends that gain new
335 /// capabilities (e.g. structured-output enforcement) should override
336 /// this method.
337 fn capabilities(&self) -> AiClientCapabilities {
338 AiClientCapabilities::default()
339 }
340
341 /// Sends a request with optional per-request settings.
342 ///
343 /// The default implementation drops `options` and dispatches via
344 /// [`Self::send_request`]. Backends that honour any field in
345 /// [`RequestOptions`] (e.g. `response_schema`) override this method.
346 /// Backends that don't honour an option must ignore it; call sites
347 /// should consult [`capabilities`](Self::capabilities) before setting
348 /// options that not all backends support.
349 fn send_request_with_options<'a>(
350 &'a self,
351 system_prompt: &'a str,
352 user_prompt: &'a str,
353 _options: RequestOptions,
354 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
355 self.send_request(system_prompt, user_prompt)
356 }
357
358 /// Sends a request and returns the response text plus per-invocation
359 /// [`InvocationMetrics`] (cost, …).
360 ///
361 /// The default implementation dispatches via
362 /// [`Self::send_request_with_options`] and reports empty metrics
363 /// (`cost_usd: None`), so backends that cannot surface a cost — including
364 /// `bedrock` and `openai` — need no change. Backends that can determine a
365 /// cost (the direct Anthropic API from token usage, `claude-cli` from its
366 /// reported `total_cost_usd`) override this method to populate the field.
367 fn send_request_with_metrics<'a>(
368 &'a self,
369 system_prompt: &'a str,
370 user_prompt: &'a str,
371 options: RequestOptions,
372 ) -> Pin<Box<dyn Future<Output = Result<AiResponse>> + Send + 'a>> {
373 Box::pin(async move {
374 let text = self
375 .send_request_with_options(system_prompt, user_prompt, options)
376 .await?;
377 Ok(AiResponse {
378 text,
379 metrics: InvocationMetrics::default(),
380 })
381 })
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn meta(provider: &str) -> AiClientMetadata {
390 AiClientMetadata {
391 provider: provider.to_string(),
392 model: "test-model".to_string(),
393 max_context_length: 1024,
394 max_response_length: 1024,
395 active_beta: None,
396 }
397 }
398
399 #[test]
400 fn prompt_style_openai() {
401 assert_eq!(meta("OpenAI").prompt_style(), PromptStyle::OpenAi);
402 }
403
404 #[test]
405 fn prompt_style_ollama() {
406 assert_eq!(meta("Ollama").prompt_style(), PromptStyle::OpenAi);
407 }
408
409 #[test]
410 fn prompt_style_anthropic() {
411 assert_eq!(meta("Anthropic").prompt_style(), PromptStyle::Claude);
412 }
413
414 #[test]
415 fn prompt_style_bedrock() {
416 assert_eq!(
417 meta("Anthropic Bedrock").prompt_style(),
418 PromptStyle::Claude
419 );
420 }
421
422 #[test]
423 fn prompt_style_unknown_defaults_to_claude() {
424 assert_eq!(meta("SomeNewProvider").prompt_style(), PromptStyle::Claude);
425 }
426
427 /// Ensure case-sensitive matching: "openai" (lowercase) is not a known provider
428 /// string and must not silently match as OpenAI.
429 #[test]
430 fn prompt_style_case_sensitive() {
431 assert_eq!(meta("openai").prompt_style(), PromptStyle::Claude);
432 assert_eq!(meta("ollama").prompt_style(), PromptStyle::Claude);
433 }
434
435 #[test]
436 fn capabilities_default_is_all_disabled() {
437 let caps = AiClientCapabilities::default();
438 assert!(!caps.supports_response_schema);
439 }
440
441 #[test]
442 fn response_format_default_is_yaml() {
443 assert_eq!(ResponseFormat::default(), ResponseFormat::Yaml);
444 }
445
446 #[test]
447 fn response_format_from_capabilities_disabled_picks_yaml() {
448 let caps = AiClientCapabilities::default();
449 assert_eq!(
450 ResponseFormat::from_capabilities(&caps),
451 ResponseFormat::Yaml
452 );
453 }
454
455 #[test]
456 fn response_format_from_capabilities_enabled_picks_json_schema() {
457 let caps = AiClientCapabilities {
458 supports_response_schema: true,
459 };
460 assert_eq!(
461 ResponseFormat::from_capabilities(&caps),
462 ResponseFormat::JsonSchema
463 );
464 }
465
466 #[test]
467 fn request_options_with_response_schema_sets_field() {
468 let value = serde_json::json!({"type": "object"});
469 let opts = RequestOptions::default().with_response_schema(value.clone());
470 assert_eq!(opts.response_schema, Some(value));
471 }
472
473 #[test]
474 fn request_options_default_has_no_schema() {
475 let opts = RequestOptions::default();
476 assert!(opts.response_schema.is_none());
477 }
478
479 #[test]
480 fn invocation_metrics_default_has_no_cost() {
481 assert_eq!(InvocationMetrics::default().cost_usd, None);
482 }
483
484 /// Cost arithmetic against a fixture price table and fixture token counts.
485 /// $3 / MTok input, $15 / MTok output; 1M input + 1M output → $18.
486 #[test]
487 fn cost_from_prices_uses_per_million_token_rates() {
488 assert!((cost_from_prices(1_000_000, 1_000_000, 3.0, 15.0) - 18.0).abs() < 1e-9);
489 // 500k input + 200k output at $3 / $15 → 1.5 + 3.0 = 4.5.
490 assert!((cost_from_prices(500_000, 200_000, 3.0, 15.0) - 4.5).abs() < 1e-9);
491 }
492
493 #[test]
494 fn cost_from_prices_zero_tokens_is_zero() {
495 assert!(cost_from_prices(0, 0, 3.0, 15.0).abs() < 1e-12);
496 }
497
498 /// A priced model in the registry yields a `Some` cost via the full
499 /// registry-backed path.
500 #[test]
501 fn compute_cost_usd_priced_model_is_some() {
502 // claude-sonnet-4-6 is priced at $3 / $15 per MTok in models.yaml.
503 let cost = compute_cost_usd("claude-sonnet-4-6", 1_000_000, 1_000_000);
504 assert_eq!(cost, Some(18.0));
505 }
506
507 /// An unpriced / unknown model yields `None` (the unknown-cost fallback).
508 #[test]
509 fn compute_cost_usd_unknown_model_is_none() {
510 assert_eq!(
511 compute_cost_usd("totally-unknown-vendor-x", 1_000, 1_000),
512 None
513 );
514 }
515
516 /// OpenAI/Gemini entries are intentionally unpriced (out of scope), so
517 /// they surface cost as unknown rather than a bogus zero.
518 #[test]
519 fn registry_token_prices_none_for_unpriced_entry() {
520 assert_eq!(registry_token_prices("gpt-5"), None);
521 }
522}