1//! Shared LLM client abstraction for Anthropic and OpenAI.
2//!
3//! This module provides a unified interface for making LLM requests,
4//! supporting both synchronous and batch modes.
5
6use crate::BatchProvider;
7use crate::anthropic_client::AnthropicClient;
8use crate::openai_client::OpenAiClient;
9use crate::paths::LLM_CACHE_DB;
10use anyhow::Result;
11
12/// A unified LLM client that wraps either Anthropic or OpenAI.
13pub enum LlmClient {
14 Anthropic(AnthropicClient),
15 OpenAi(OpenAiClient),
16}
17
18impl LlmClient {
19 /// Create a new LLM client for the given backend.
20 ///
21 /// If `batched` is true, requests will be queued for batch processing.
22 /// Otherwise, requests are made synchronously.
23 pub fn new(backend: BatchProvider, batched: bool) -> Result<Self> {
24 match backend {
25 BatchProvider::Anthropic => {
26 if batched {
27 Ok(LlmClient::Anthropic(AnthropicClient::batch(&LLM_CACHE_DB)?))
28 } else {
29 Ok(LlmClient::Anthropic(AnthropicClient::plain()?))
30 }
31 }
32 BatchProvider::Openai => {
33 if batched {
34 Ok(LlmClient::OpenAi(OpenAiClient::batch(&LLM_CACHE_DB)?))
35 } else {
36 Ok(LlmClient::OpenAi(OpenAiClient::plain()?))
37 }
38 }
39 }
40 }
41
42 /// Generate a response from the LLM.
43 ///
44 /// Returns `Ok(None)` if the request was queued for batch processing
45 /// and results are not yet available.
46 pub async fn generate(
47 &self,
48 model: &str,
49 max_tokens: u64,
50 prompt: &str,
51 ) -> Result<Option<String>> {
52 match self {
53 LlmClient::Anthropic(client) => {
54 let messages = vec![anthropic::Message {
55 role: anthropic::Role::User,
56 content: vec![anthropic::RequestContent::Text {
57 text: prompt.to_string(),
58 cache_control: None,
59 }],
60 }];
61 let response = client.generate(model, max_tokens, messages, None).await?;
62 Ok(response.map(|r| {
63 r.content
64 .iter()
65 .filter_map(|c| match c {
66 anthropic::ResponseContent::Text { text } => Some(text.as_str()),
67 _ => None,
68 })
69 .collect::<Vec<_>>()
70 .join("")
71 }))
72 }
73 LlmClient::OpenAi(client) => {
74 let messages = vec![open_ai::RequestMessage::User {
75 content: open_ai::MessageContent::Plain(prompt.to_string()),
76 }];
77 let response = client.generate(model, max_tokens, messages, None).await?;
78 Ok(response.map(|r| {
79 r.choices
80 .into_iter()
81 .filter_map(|choice| match choice.message {
82 open_ai::RequestMessage::Assistant { content, .. } => {
83 content.map(|c| match c {
84 open_ai::MessageContent::Plain(text) => text,
85 open_ai::MessageContent::Multipart(parts) => parts
86 .into_iter()
87 .filter_map(|p| match p {
88 open_ai::MessagePart::Text { text } => Some(text),
89 _ => None,
90 })
91 .collect::<Vec<_>>()
92 .join(""),
93 })
94 }
95 _ => None,
96 })
97 .collect::<Vec<_>>()
98 .join("")
99 }))
100 }
101 }
102 }
103
104 /// Sync pending batches - upload queued requests and download completed results.
105 pub async fn sync_batches(&self) -> Result<()> {
106 match self {
107 LlmClient::Anthropic(client) => client.sync_batches().await,
108 LlmClient::OpenAi(client) => client.sync_batches().await,
109 }
110 }
111}
112
113/// Get the model name for a given backend.
114pub fn model_for_backend(backend: BatchProvider) -> &'static str {
115 match backend {
116 BatchProvider::Anthropic => "claude-sonnet-4-5",
117 BatchProvider::Openai => "gpt-5.2",
118 }
119}