Skip to main content

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}