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