polyc_llm/lib.rs
1//! Provider-agnostic LLM trait and wire types for polychrome.
2//!
3//! This crate defines the [`LlmProvider`] trait that every concrete provider
4//! backend implements, and the language-shaped Rust types that flow through it.
5//!
6//! [`LlmProvider`] is the seam that lets the planner swap LLM backends
7//! without touching its own code: one impl crate per provider, dispatched
8//! behind a `dyn LlmProvider` trait object.
9//!
10//! # Modules
11//!
12//! - [`request`] — [`CompletionRequest`] and everything reachable from it
13//! ([`Message`], [`Content`], [`ToolSpec`], [`ToolChoice`]).
14//! - [`chunk`] — [`Chunk`] (streaming response events) and friends
15//! ([`Usage`], [`StopReason`]).
16//! - [`error`] — the [`LlmError`] trait bound that [`LlmProvider::Error`]
17//! must satisfy.
18//!
19//! The trait itself lives in this crate root; the wire types live in the
20//! modules above and are re-exported here for convenience.
21
22pub mod chunk;
23pub mod erased;
24pub mod error;
25mod metrics;
26pub mod model_info;
27pub mod preflight;
28pub mod request;
29pub mod sse;
30pub mod turn;
31
32use async_trait::async_trait;
33use futures::stream::BoxStream;
34
35pub use chunk::{Chunk, StopReason, Usage};
36pub use erased::{BoxError, DynProvider, ErasedProvider, into_dyn};
37pub use error::{LlmError, LlmErrorKind, kind_from_http_status, parse_retry_after};
38pub use model_info::{FALLBACK_CONTEXT_WINDOW, ModelInfo, lookup_model, lookup_model_or_fallback};
39pub use preflight::{PreflightReport, ProbeOutcome, preflight};
40pub use request::{
41 APPROVAL_STATUS_GROUND_RULE, CacheHint, CompletionRequest, Content, GATED_TOOL_APPROVAL_NOTE,
42 ImageRef, JsonSchema, Message, Role, ToolCall, ToolChoice, ToolResult, ToolSpec,
43};
44
45/// Force-register this crate's Prometheus call-latency histogram.
46///
47/// Makes it appear in a `/metrics` scrape immediately — before any provider
48/// call has completed. Idempotent (backed by a `OnceLock`); call once at
49/// process startup, alongside any other crate's own `init_metrics`.
50pub fn init_metrics() {
51 metrics::force();
52}
53
54/// The seam between the planner and any concrete LLM backend.
55///
56/// One implementation per backend, registered at startup and dispatched behind
57/// a trait object so the planner swaps backends without recompiling. A single
58/// method —
59/// [`complete`](LlmProvider::complete) — takes a [`CompletionRequest`] and
60/// returns a stream of [`Chunk`]s; non-streaming callers simply drain the
61/// stream.
62///
63/// The `'static` bound and [`Send`] + [`Sync`] make providers storable in the
64/// control plane's routing table (`arc-swap`'d) and shareable across tasks.
65/// [`Self::Error`] is bounded by [`LlmError`] so failures are uniform across
66/// providers while each keeps its own concrete error type.
67#[async_trait]
68pub trait LlmProvider: Send + Sync + 'static {
69 /// The provider's concrete error type. Bounded by [`LlmError`]
70 /// (`std::error::Error + Send + Sync + 'static`).
71 type Error: LlmError;
72
73 /// Runs a completion, returning a stream of [`Chunk`]s.
74 ///
75 /// The outer `Result` reports failures that occur before the stream opens
76 /// (auth, request validation, transport dial). Once the stream is live,
77 /// per-chunk failures surface as `Err` items within it — a stream can yield
78 /// several good chunks and then fault mid-flight.
79 ///
80 /// # Errors
81 ///
82 /// Returns [`Self::Error`] if the request cannot be dispatched or the
83 /// provider rejects it before streaming begins.
84 async fn complete(
85 &self,
86 req: CompletionRequest,
87 ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>;
88}
89
90#[cfg(test)]
91mod tests {
92 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
93
94 use futures::{StreamExt, stream};
95
96 use super::{Chunk, CompletionRequest, LlmProvider, StopReason, Usage, error::DummyError};
97
98 /// Reference provider: echoes the first user message back as text, then a
99 /// usage tally and an end-of-turn stop. Proves the trait is implementable
100 /// and that its stream can be driven to completion.
101 struct EchoProvider;
102
103 #[async_trait::async_trait]
104 impl LlmProvider for EchoProvider {
105 type Error = DummyError;
106
107 async fn complete(
108 &self,
109 req: CompletionRequest,
110 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
111 {
112 if req.messages.is_empty() {
113 return Err(DummyError::Other("no messages".to_owned()));
114 }
115 let chunks = vec![
116 Ok(Chunk::text_delta("echo")),
117 Ok(Chunk::Usage(Usage {
118 input_tokens: 3,
119 output_tokens: 1,
120 ..Default::default()
121 })),
122 Ok(Chunk::Stop(StopReason::EndTurn)),
123 ];
124 Ok(stream::iter(chunks).boxed())
125 }
126 }
127
128 #[tokio::test]
129 async fn provider_streams_chunks_to_completion() {
130 let provider = EchoProvider;
131 let mut req = CompletionRequest::new("test-model");
132 req.messages.push(super::Message::user("hi"));
133
134 let stream = provider.complete(req).await.expect("stream opens");
135 let collected: Vec<Chunk> = stream.map(Result::unwrap).collect().await;
136
137 assert_eq!(collected.len(), 3);
138 assert_eq!(collected[0], Chunk::text_delta("echo"));
139 assert!(matches!(collected[2], Chunk::Stop(StopReason::EndTurn)));
140 }
141
142 #[tokio::test]
143 async fn provider_reports_pre_stream_failure() {
144 let provider = EchoProvider;
145 let req = CompletionRequest::new("test-model"); // no messages
146
147 match provider.complete(req).await {
148 Err(DummyError::Other(_)) => {}
149 Err(other) => panic!("wrong error: {other}"),
150 Ok(_) => panic!("expected pre-stream rejection"),
151 }
152 }
153
154 #[tokio::test]
155 async fn usable_as_trait_object() {
156 // The PRD calls for `dyn LlmProvider` dispatch; confirm object safety.
157 let provider: Box<dyn LlmProvider<Error = DummyError>> = Box::new(EchoProvider);
158 let mut req = CompletionRequest::new("m");
159 req.messages.push(super::Message::user("yo"));
160 let stream = provider.complete(req).await.expect("stream opens");
161 let n = stream.count().await;
162 assert_eq!(n, 3);
163 }
164}