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