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}