Skip to main content

locode_core/
providers.rs

1//! Custom-provider injection (ADR-0015): a name → factory registry the binary
2//! entry points thread through to session assembly.
3//!
4//! `ProviderRegistry::builtin()` carries the built-in wires; a downstream
5//! binary crate registers its own [`Provider`] implementations under new names
6//! and selects them with `--api-schema <name>` — no fork of the CLI needed.
7
8use std::fmt;
9use std::sync::Arc;
10
11use locode_protocol::{ContentBlock, Usage};
12use locode_provider::{
13    AnthropicProvider, Completion, MockProvider, OpenAiResponsesProvider, Provider, StopReason,
14};
15
16/// Per-run inputs a factory may use when constructing its provider.
17pub struct ProviderInit {
18    /// The session id — e.g. for cache-routing hints (the `openai-responses`
19    /// wire sets it as `prompt_cache_key`, codex's rule).
20    pub session_id: String,
21    /// A model override (the `model` settings field, ADR-0024 §1.4). `None` =
22    /// the factory's own default (env/config). Built-in factories honor it;
23    /// custom factories may ignore it.
24    pub model: Option<String>,
25}
26
27/// A constructed provider plus the model name it resolved (for the report
28/// envelope and engine config).
29pub struct BuiltProvider {
30    /// The wire, ready to drive.
31    pub provider: Arc<dyn Provider>,
32    /// The resolved model id (config/env/default — the factory decides).
33    pub model: String,
34}
35
36impl fmt::Debug for BuiltProvider {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.debug_struct("BuiltProvider")
39            .field("model", &self.model)
40            .finish_non_exhaustive() // the dyn provider has no Debug bound
41    }
42}
43
44/// A provider construction failure (missing env, bad config, unknown name):
45/// pre-run by nature — surfaced before any session starts.
46#[derive(Debug)]
47pub struct ProviderBuildError(pub String);
48
49impl fmt::Display for ProviderBuildError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(&self.0)
52    }
53}
54
55impl std::error::Error for ProviderBuildError {}
56
57/// A named provider constructor: env/config resolution belongs inside the
58/// factory so selection stays lazy — only the chosen wire pays its setup.
59pub type ProviderFactory =
60    Box<dyn Fn(&ProviderInit) -> Result<BuiltProvider, ProviderBuildError> + Send + Sync>;
61
62/// The `--api-schema` name space: ordered name → factory entries.
63///
64/// Ordered so help/error listings are stable; [`ProviderRegistry::register`]
65/// replaces in place when the name already exists (letting a downstream binary
66/// override a built-in, e.g. a scripted `mock`).
67pub struct ProviderRegistry {
68    entries: Vec<(String, ProviderFactory)>,
69}
70
71impl ProviderRegistry {
72    /// An empty registry (no names). Most callers want [`Self::builtin`].
73    #[must_use]
74    pub fn new() -> Self {
75        ProviderRegistry {
76            entries: Vec::new(),
77        }
78    }
79
80    /// The built-in wires: `anthropic`, `openai-responses`, and the keyless
81    /// `mock` (one scripted no-tool text turn → `completed`, for CI).
82    ///
83    /// The `mock` script is overridable via the `LOCODE_MOCK_SCRIPT` env var —
84    /// a JSON array of turns, each `{"text": "…"}` (a final text turn) or
85    /// `{"tool": "<name>", "input": {…}}` (a `tool_use` turn) — so keyless
86    /// integration tests can drive multi-turn/tool runs (e.g. the SIGTERM
87    /// test holds a run open with a slow shell command). A malformed script
88    /// fails pre-run.
89    #[must_use]
90    pub fn builtin() -> Self {
91        Self::new()
92            .register("anthropic", |init| {
93                let mut provider = AnthropicProvider::from_env()
94                    .map_err(|e| ProviderBuildError(format!("anthropic wire: {e}")))?;
95                // The settings `model` override (ADR-0024 §1.4) beats the
96                // factory's env/config default.
97                if let Some(model) = &init.model {
98                    provider.config_mut().model.clone_from(model);
99                }
100                let model = provider.config().model.clone();
101                Ok(BuiltProvider {
102                    provider: Arc::new(provider),
103                    model,
104                })
105            })
106            .register("openai-responses", |init| {
107                let mut provider = OpenAiResponsesProvider::from_env()
108                    .map_err(|e| ProviderBuildError(format!("openai-responses wire: {e}")))?;
109                // Cache-routing hint = the session id (codex's rule; probe-verified
110                // harmless for xAI models).
111                provider.config_mut().prompt_cache_key = Some(init.session_id.clone());
112                if let Some(model) = &init.model {
113                    provider.config_mut().model.clone_from(model);
114                }
115                let model = provider.config().model.clone();
116                Ok(BuiltProvider {
117                    provider: Arc::new(provider),
118                    model,
119                })
120            })
121            .register("mock", |init| {
122                let script = match std::env::var("LOCODE_MOCK_SCRIPT") {
123                    Ok(json) => mock_script(&json)
124                        .map_err(|e| ProviderBuildError(format!("LOCODE_MOCK_SCRIPT: {e}")))?,
125                    Err(_) => vec![Completion {
126                        content: vec![ContentBlock::Text {
127                            text: "Mock run complete.".to_string(),
128                        }],
129                        usage: Usage::default(),
130                        stop: StopReason::EndTurn,
131                    }],
132                };
133                Ok(BuiltProvider {
134                    provider: Arc::new(MockProvider::new(script)),
135                    // Honor the model override like the real wires do, so the
136                    // flag > settings > default chain is observable keylessly.
137                    model: init.model.clone().unwrap_or_else(|| "mock-1".to_string()),
138                })
139            })
140    }
141
142    /// Add a provider under `name`, replacing any existing entry of that name
143    /// (registration order is otherwise preserved for listings).
144    #[must_use]
145    pub fn register<F>(mut self, name: impl Into<String>, factory: F) -> Self
146    where
147        F: Fn(&ProviderInit) -> Result<BuiltProvider, ProviderBuildError> + Send + Sync + 'static,
148    {
149        let name = name.into();
150        let factory: ProviderFactory = Box::new(factory);
151        match self.entries.iter_mut().find(|(n, _)| *n == name) {
152            Some(entry) => entry.1 = factory,
153            None => self.entries.push((name, factory)),
154        }
155        self
156    }
157
158    /// The registered names, in registration order.
159    #[must_use]
160    pub fn names(&self) -> Vec<&str> {
161        self.entries.iter().map(|(n, _)| n.as_str()).collect()
162    }
163
164    /// Construct the provider registered under `name`.
165    ///
166    /// # Errors
167    /// [`ProviderBuildError`] when `name` is unknown (the message lists the
168    /// available names) or when the factory itself fails (missing env, …).
169    pub fn build(
170        &self,
171        name: &str,
172        init: &ProviderInit,
173    ) -> Result<BuiltProvider, ProviderBuildError> {
174        let factory = self
175            .entries
176            .iter()
177            .find(|(n, _)| n == name)
178            .map(|(_, f)| f)
179            .ok_or_else(|| {
180                ProviderBuildError(format!(
181                    "unknown --api-schema `{name}`; available: {}",
182                    self.names().join(", ")
183                ))
184            })?;
185        factory(init)
186    }
187}
188
189impl Default for ProviderRegistry {
190    fn default() -> Self {
191        Self::builtin()
192    }
193}
194
195/// Parse a `LOCODE_MOCK_SCRIPT` value into mock completions.
196///
197/// Format: a JSON array; each element is `{"text": "…"}` (an end-turn text
198/// completion) or `{"tool": "<name>", "input": {…}}` (a single `tool_use`
199/// completion — `input` defaults to `{}`, ids are `call_0`, `call_1`, …).
200fn mock_script(json: &str) -> Result<Vec<Completion>, String> {
201    let value: serde_json::Value =
202        serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
203    let turns = value
204        .as_array()
205        .ok_or_else(|| "expected a JSON array of turns".to_string())?;
206    if turns.is_empty() {
207        return Err("expected at least one turn".to_string());
208    }
209    turns
210        .iter()
211        .enumerate()
212        .map(|(i, turn)| {
213            if let Some(text) = turn.get("text").and_then(serde_json::Value::as_str) {
214                Ok(Completion {
215                    content: vec![ContentBlock::Text {
216                        text: text.to_string(),
217                    }],
218                    usage: Usage::default(),
219                    stop: StopReason::EndTurn,
220                })
221            } else if let Some(tool) = turn.get("tool").and_then(serde_json::Value::as_str) {
222                Ok(Completion {
223                    content: vec![ContentBlock::ToolUse {
224                        id: format!("call_{i}"),
225                        name: tool.to_string(),
226                        input: turn
227                            .get("input")
228                            .cloned()
229                            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
230                    }],
231                    usage: Usage::default(),
232                    stop: StopReason::ToolUse,
233                })
234            } else {
235                Err(format!(
236                    "turn {i}: expected {{\"text\": …}} or {{\"tool\": …, \"input\": …}}"
237                ))
238            }
239        })
240        .collect()
241}