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/// 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}