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}