Skip to main content

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}