Skip to main content

agentyk_core/
driver.rs

1//! LLM drivers — the multi-provider seam.
2//!
3//! Keeps the everruns driver vocabulary: a [`ChatDriver`] implements one
4//! provider protocol, a [`DriverRegistry`] routes by [`DriverId`], and a
5//! [`ModelSpec`] (everruns: `ResolvedModel`) is the by-value description of
6//! "which model, through which driver, with which credentials" — no model or
7//! provider ids, no registration ceremony.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16use crate::error::Result;
17use crate::message::Message;
18use crate::tool::ToolDefinition;
19
20/// Names a provider protocol implementation, e.g. `"openai"`, `"anthropic"`,
21/// `"llmsim"`. An open string (not a closed enum) so embedders can register
22/// their own drivers.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(transparent)]
25pub struct DriverId(String);
26
27impl DriverId {
28    /// Name a driver protocol. Use the constructors below for the bundled
29    /// ones; this is for your own.
30    pub fn new(id: impl Into<String>) -> Self {
31        Self(id.into())
32    }
33
34    /// The id as written on the wire and in a [`ModelSpec`].
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38
39    /// `"openai"` — the Chat Completions protocol, which many other
40    /// providers also speak.
41    pub fn openai() -> Self {
42        Self::new("openai")
43    }
44
45    /// `"anthropic"` — the Messages protocol.
46    pub fn anthropic() -> Self {
47        Self::new("anthropic")
48    }
49
50    /// `"llmsim"` — the scripted offline simulator, for tests and examples.
51    pub fn llmsim() -> Self {
52        Self::new("llmsim")
53    }
54}
55
56impl fmt::Display for DriverId {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62impl From<&str> for DriverId {
63    fn from(s: &str) -> Self {
64        Self::new(s)
65    }
66}
67
68/// Reasoning/thinking configuration for models that support it. Mirrors
69/// everruns' `ReasoningConfig`.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
71#[non_exhaustive]
72pub struct ReasoningConfig {
73    /// Provider-defined effort level, e.g. `"low"` / `"medium"` / `"high"`.
74    /// Honored by OpenAI-shaped drivers as `reasoning_effort`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub effort: Option<String>,
77    /// Token budget for extended thinking. Anthropic's thinking is enabled
78    /// per-request with an integer budget (not an effort string), so it takes
79    /// this rather than `effort`.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub budget_tokens: Option<u32>,
82}
83
84/// A model, by value (everruns: `ResolvedModel`): wire model name, the driver
85/// that speaks its protocol, and optional credentials/endpoint. Build it
86/// inline and hand it to the agent — nothing to register.
87#[derive(Clone, PartialEq, Serialize, Deserialize)]
88#[non_exhaustive]
89pub struct ModelSpec {
90    /// Which protocol implementation to route through.
91    pub driver: DriverId,
92    /// The provider's own model name, sent verbatim.
93    pub model: String,
94    /// Credential for this model. Some drivers require one
95    /// (Anthropic); others accept none, for local runtimes and proxies.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub api_key: Option<String>,
98    /// Override the driver's default endpoint (OpenAI-compatible servers,
99    /// proxies, local runtimes).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub base_url: Option<String>,
102    /// Reasoning/thinking request, for models that support it. Honored per
103    /// driver — see [`ReasoningConfig`].
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub reasoning: Option<ReasoningConfig>,
106    /// Generic, serializable hatch for provider-flavored configuration core
107    /// should not grow a field for — everruns' `ProviderMetadata`, in the
108    /// shape [`crate::tool::ToolDefinition::metadata`] established.
109    ///
110    /// `api_key` + `base_url` covers a plain API-key provider. It does not
111    /// cover the rest of what real providers ask for: an OAuth
112    /// subscription's refresh token, account id, and expiry; an
113    /// organization or project id; extra headers a gateway requires. Those
114    /// are provider-specific, so they ride here and the driver that
115    /// understands them reads them, rather than every adopter waiting on a
116    /// core release for a field only one provider uses.
117    ///
118    /// **Treat it as sensitive.** It is the natural home for credentials, so
119    /// it is redacted in `Debug` alongside `api_key`, and a `ModelSpec` must
120    /// never reach an event or a log line.
121    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
122    pub metadata: serde_json::Value,
123}
124
125impl std::fmt::Debug for ModelSpec {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("ModelSpec")
128            .field("driver", &self.driver)
129            .field("model", &self.model)
130            .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
131            .field("base_url", &self.base_url)
132            .field("reasoning", &self.reasoning)
133            .field(
134                "metadata",
135                &(!self.metadata.is_null()).then_some("<redacted>"),
136            )
137            .finish()
138    }
139}
140
141impl ModelSpec {
142    /// A model on an explicitly named driver. Prefer [`ModelSpec::openai`]
143    /// or [`ModelSpec::anthropic`] for the bundled ones.
144    pub fn new(driver: impl Into<DriverId>, model: impl Into<String>) -> Self {
145        Self {
146            driver: driver.into(),
147            model: model.into(),
148            api_key: None,
149            base_url: None,
150            reasoning: None,
151            metadata: serde_json::Value::Null,
152        }
153    }
154
155    /// A model served over the OpenAI Chat Completions protocol. Point it
156    /// at any compatible endpoint with [`ModelSpec::base_url`].
157    pub fn openai(model: impl Into<String>) -> Self {
158        Self::new(DriverId::openai(), model)
159    }
160
161    /// A model served over the Anthropic Messages protocol.
162    pub fn anthropic(model: impl Into<String>) -> Self {
163        Self::new(DriverId::anthropic(), model)
164    }
165
166    /// The scripted offline simulator (the `SimDriver` in the `agentyk`
167    /// framework crate).
168    pub fn llmsim() -> Self {
169        Self::new(DriverId::llmsim(), "llmsim")
170    }
171
172    /// Attach the credential this model authenticates with.
173    pub fn api_key(mut self, key: impl Into<String>) -> Self {
174        self.api_key = Some(key.into());
175        self
176    }
177
178    /// Request a reasoning/thinking effort level, where the driver supports
179    /// it (currently honored by [`crate::driver::DriverId::openai`]-shaped
180    /// drivers via `reasoning_effort`).
181    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
182        let mut reasoning = self.reasoning.unwrap_or_default();
183        reasoning.effort = Some(effort.into());
184        self.reasoning = Some(reasoning);
185        self
186    }
187
188    /// Enable extended thinking with a token budget, where the driver supports
189    /// it (currently the Anthropic driver, which sends
190    /// `thinking: { budget_tokens }`).
191    pub fn thinking_budget(mut self, budget_tokens: u32) -> Self {
192        let mut reasoning = self.reasoning.unwrap_or_default();
193        reasoning.budget_tokens = Some(budget_tokens);
194        self.reasoning = Some(reasoning);
195        self
196    }
197
198    /// Send to a different endpoint than the driver's default — an
199    /// OpenAI-compatible server, a proxy, a local runtime.
200    pub fn base_url(mut self, url: impl Into<String>) -> Self {
201        self.base_url = Some(url.into());
202        self
203    }
204
205    /// Attach provider-specific configuration a driver knows how to read —
206    /// see [`ModelSpec::metadata`].
207    ///
208    /// ```
209    /// # use agentyk_core::ModelSpec;
210    /// let model = ModelSpec::openai("gpt-5.5")
211    ///     .metadata(serde_json::json!({ "organization": "org_123" }));
212    /// assert_eq!(model.metadata["organization"], "org_123");
213    /// ```
214    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
215        self.metadata = metadata;
216        self
217    }
218}
219
220/// One LLM completion request: everything a driver needs to produce the next
221/// assistant message.
222#[derive(Debug, Clone)]
223#[non_exhaustive]
224pub struct ChatRequest {
225    /// Which model, through which driver, with which credentials.
226    pub model: ModelSpec,
227    /// The assembled system prompt: the agent's, plus every capability's
228    /// contribution.
229    pub system_prompt: Option<String>,
230    /// Conversation history, oldest first.
231    pub messages: Vec<Message>,
232    /// The tools offered to the model this turn. Deferred tools are
233    /// executable but deliberately absent here.
234    pub tools: Vec<ToolDefinition>,
235}
236
237impl ChatRequest {
238    /// The minimum a completion needs: a model and a conversation.
239    pub fn new(model: ModelSpec, messages: Vec<Message>) -> Self {
240        Self {
241            model,
242            system_prompt: None,
243            messages,
244            tools: Vec::new(),
245        }
246    }
247
248    /// Set the system prompt.
249    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
250        self.system_prompt = Some(prompt.into());
251        self
252    }
253
254    /// Set the system prompt from an `Option`, for callers that assembled
255    /// one that may be empty.
256    pub fn maybe_system_prompt(mut self, prompt: Option<String>) -> Self {
257        self.system_prompt = prompt;
258        self
259    }
260
261    /// Offer these tools to the model.
262    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
263        self.tools = tools;
264        self
265    }
266}
267
268/// Token counts for one completion.
269///
270/// `input_tokens`/`output_tokens` are the totals every provider reports. The
271/// remaining fields are *breakdowns* of those totals — subsets, never addends:
272/// cached and cache-writing tokens are part of `input_tokens`, reasoning
273/// tokens part of `output_tokens`. They are zero when the provider does not
274/// report them.
275///
276/// The breakdown is here rather than left to each host because it is the only
277/// evidence a caller has that prompt caching worked: the driver places
278/// `cache_control` breakpoints, and without `cache_read_input_tokens` a host
279/// cannot tell a cache hit from a cache miss — the tokens are billed at very
280/// different rates but look identical in the total. Evals grade it; the same
281/// numbers drive cost accounting.
282#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
283#[non_exhaustive]
284pub struct Usage {
285    /// Tokens consumed by the prompt, cached ones included.
286    pub input_tokens: u64,
287    /// Tokens produced by the model, reasoning tokens included where the
288    /// provider counts them.
289    pub output_tokens: u64,
290    /// Prompt tokens served from the provider's cache — a subset of
291    /// [`input_tokens`](Self::input_tokens). Zero when unreported.
292    #[serde(default)]
293    pub cache_read_input_tokens: u64,
294    /// Prompt tokens written to the provider's cache this request — a subset
295    /// of [`input_tokens`](Self::input_tokens), billed at a premium. Zero when
296    /// unreported.
297    #[serde(default)]
298    pub cache_creation_input_tokens: u64,
299    /// Reasoning/thinking tokens — a subset of
300    /// [`output_tokens`](Self::output_tokens). Zero when unreported.
301    #[serde(default)]
302    pub reasoning_tokens: u64,
303}
304
305impl Usage {
306    /// Record one completion's token totals, with no breakdown.
307    pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
308        Self {
309            input_tokens,
310            output_tokens,
311            ..Self::default()
312        }
313    }
314
315    /// Attach the prompt-cache breakdown: tokens served from the cache and
316    /// tokens written to it. Both are already counted in `input_tokens`.
317    pub fn with_cache(mut self, read: u64, creation: u64) -> Self {
318        self.cache_read_input_tokens = read;
319        self.cache_creation_input_tokens = creation;
320        self
321    }
322
323    /// Attach the reasoning-token breakdown, already counted in
324    /// `output_tokens`.
325    pub fn with_reasoning(mut self, reasoning_tokens: u64) -> Self {
326        self.reasoning_tokens = reasoning_tokens;
327        self
328    }
329
330    /// Accumulate another completion's usage — how a turn totals its cost
331    /// across iterations.
332    pub fn add(&mut self, other: Usage) {
333        self.input_tokens += other.input_tokens;
334        self.output_tokens += other.output_tokens;
335        self.cache_read_input_tokens += other.cache_read_input_tokens;
336        self.cache_creation_input_tokens += other.cache_creation_input_tokens;
337        self.reasoning_tokens += other.reasoning_tokens;
338    }
339}
340
341/// What one completion produced.
342#[derive(Debug, Clone)]
343#[non_exhaustive]
344pub struct ChatResponse {
345    /// The assistant message: final text and/or tool calls.
346    pub message: Message,
347    /// What it cost.
348    pub usage: Usage,
349}
350
351impl ChatResponse {
352    /// Pair a generated message with its token usage.
353    pub fn new(message: Message, usage: Usage) -> Self {
354        Self { message, usage }
355    }
356}
357
358/// Receives incremental text as a [`ChatDriver`] streams a completion.
359/// `accumulated` is the full text so far, including `delta` — the executor
360/// forwards each call to the engine host's event recorder as an
361/// ephemeral `output.message.delta` event.
362#[async_trait]
363pub trait DeltaSink: Send {
364    /// Report newly generated text. `delta` is the increment; `accumulated`
365    /// is everything so far, including it.
366    ///
367    /// Returning an error stops the stream — which is how cancellation takes
368    /// effect mid-completion rather than after it.
369    async fn delta(&mut self, delta: &str, accumulated: &str) -> Result<()>;
370}
371
372/// One provider protocol. Implementations translate [`ChatRequest`] to the
373/// provider's wire format and back.
374#[async_trait]
375pub trait ChatDriver: Send + Sync {
376    /// The protocol id this driver answers to; a [`ModelSpec`] naming it
377    /// routes here.
378    fn id(&self) -> DriverId;
379
380    /// Produce the next assistant message.
381    ///
382    /// Errors should be [`crate::error::Error::Driver`] with a
383    /// [`crate::error::LlmErrorKind`], so a host can tell a transient failure
384    /// from a terminal one.
385    async fn complete(&self, request: ChatRequest) -> Result<ChatResponse>;
386
387    /// Stream a completion, reporting text as it arrives through `sink`, and
388    /// return the same final [`ChatResponse`] [`complete`](Self::complete)
389    /// would. The default forwards to `complete` and reports the whole
390    /// response as a single delta — real incremental streaming is an
391    /// opt-in per-driver upgrade (see the `http` feature's OpenAI/Anthropic
392    /// drivers), not a requirement of the trait.
393    async fn complete_streaming(
394        &self,
395        request: ChatRequest,
396        sink: &mut dyn DeltaSink,
397    ) -> Result<ChatResponse> {
398        let response = self.complete(request).await?;
399        let text = response.message.text();
400        if !text.is_empty() {
401            sink.delta(&text, &text).await?;
402        }
403        Ok(response)
404    }
405}
406
407/// Routes a [`ModelSpec`] to the [`ChatDriver`] that speaks its protocol.
408#[derive(Default, Clone)]
409pub struct DriverRegistry {
410    drivers: HashMap<DriverId, Arc<dyn ChatDriver>>,
411}
412
413impl DriverRegistry {
414    /// An empty registry.
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Add a driver, replacing any already registered under the same id.
420    pub fn register(&mut self, driver: impl ChatDriver + 'static) {
421        self.register_arc(Arc::new(driver));
422    }
423
424    /// Add a driver you already hold as an `Arc` — e.g. one shared between
425    /// agents.
426    pub fn register_arc(&mut self, driver: Arc<dyn ChatDriver>) {
427        self.drivers.insert(driver.id(), driver);
428    }
429
430    /// The driver registered under `id`, if any.
431    pub fn get(&self, id: &DriverId) -> Option<Arc<dyn ChatDriver>> {
432        self.drivers.get(id).cloned()
433    }
434
435    /// Whether a driver is registered under `id`.
436    pub fn contains(&self, id: &DriverId) -> bool {
437        self.drivers.contains_key(id)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::ModelSpec;
444
445    #[test]
446    fn model_debug_redacts_credentials() {
447        let model = ModelSpec::openai("test-model").api_key("super-secret");
448
449        let debug = format!("{model:?}");
450
451        assert!(debug.contains("<redacted>"));
452        assert!(!debug.contains("super-secret"));
453    }
454}