1use std::collections::BTreeMap;
8
9pub mod catalog;
10pub mod ping;
11pub mod reasoning;
12pub mod registry;
13pub mod stream;
14pub mod translate;
15pub mod types;
16pub mod wire;
17
18use std::sync::Arc;
19
20use crate::extensions::manager::ExtensionManager;
21use crate::extensions::providers::ProviderRegistry;
22use crate::tools::{ToolCapabilities, ToolChannels, ToolContext, ToolLimits};
23
24pub use types::{
25 ChatMessage, ChatOptions, ChatRequest, FunctionCall, FunctionDefinition, OaiEvent,
26 ProviderConfig, StreamOptions, ToolCall, ToolChoice, ToolDefinition,
27};
28pub use wire::StreamDecoder;
29
30static EXTENSION_MANAGER: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<ExtensionManager>>>> =
31 std::sync::RwLock::new(None);
32
33pub fn set_extension_manager_for_routing(manager: Arc<tokio::sync::RwLock<ExtensionManager>>) {
34 *EXTENSION_MANAGER
35 .write()
36 .expect("extension manager routing lock poisoned") = Some(manager);
37}
38
39pub fn clear_extension_manager_for_routing() {
40 *EXTENSION_MANAGER
41 .write()
42 .expect("extension manager routing lock poisoned") = None;
43}
44
45pub fn extension_manager_for_routing() -> Option<Arc<tokio::sync::RwLock<ExtensionManager>>> {
46 EXTENSION_MANAGER
47 .read()
48 .expect("extension manager routing lock poisoned")
49 .clone()
50}
51
52#[derive(Debug, Clone)]
54pub enum Provider {
55 Anthropic,
57 OpenAi(ProviderConfig),
59 Codex(ProviderConfig),
61 MissingKey(String),
63}
64
65pub fn resolve_route(model: &str, provider_keys: &BTreeMap<String, String>) -> Provider {
71 if let Some((prefix, _rest)) = model.split_once('/') {
72 if prefix == "openai-codex" {
73 if let Some(cfg) = registry::resolve_codex_shorthand(model) {
74 return Provider::Codex(cfg);
75 }
76 return Provider::MissingKey(prefix.to_string());
77 }
78 if prefix == "local" || registry::providers().iter().any(|s| s.key == prefix) {
79 if let Some(cfg) = registry::resolve_shorthand(model, provider_keys) {
80 return Provider::OpenAi(cfg);
81 }
82 return Provider::MissingKey(prefix.to_string());
84 }
85 }
86 Provider::Anthropic
87}
88
89#[allow(clippy::too_many_arguments)]
98pub async fn try_route(
99 model: &str,
100 client: &reqwest::Client,
101 tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
102 system_prompt: &Option<String>,
103 messages: &[serde_json::Value],
104 tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
105 temperature: Option<f32>,
106 max_tokens: Option<u32>,
107 thinking_budget: u32,
108 cancel: &tokio_util::sync::CancellationToken,
109 source: &crate::auth::CredentialSource,
110 cache: &crate::auth::TokenCache,
111) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
112 if let Some((plugin_id, provider_id, model_id)) = ProviderRegistry::parse_model_id(model) {
113 if let Some(manager) = extension_manager_for_routing() {
114 let provider_runtime_id = format!("{}:{}", plugin_id, provider_id);
115 let Some((handler, hook_bus, tools_shared, streaming, model_tool_use)) = ({
116 let manager = manager.read().await;
117 manager.provider(&provider_runtime_id).and_then(|provider| {
118 provider.handler.as_ref().map(|handler| {
119 let model_spec = provider.spec.models.iter().find(|m| m.id == model_id);
120 let streaming = model_spec
121 .and_then(|m| m.capabilities.get("streaming"))
122 .and_then(|v| v.as_bool())
123 .unwrap_or(false);
124 let model_tool_use = model_spec
125 .and_then(|m| m.capabilities.get("tool_use"))
126 .and_then(|v| v.as_bool())
127 .unwrap_or(false);
128 (
129 handler.clone(),
130 manager.hook_bus().clone(),
131 manager.tools_shared(),
132 streaming,
133 model_tool_use,
134 )
135 })
136 })
137 }) else {
138 return Some(Err(format!(
139 "Extension provider model '{}' is not available",
140 model
141 )
142 .into()));
143 };
144 let trust = match crate::extensions::trust::load_trust_state() {
148 Ok(t) => t,
149 Err(e) => {
150 tracing::warn!("trust.json corrupt or unreadable, failing closed: {e}");
151 return Some(Err(format!(
152 "Cannot route to provider: trust state unreadable: {e}"
153 )
154 .into()));
155 }
156 };
157 if !crate::extensions::trust::is_provider_enabled(&trust, &provider_runtime_id) {
158 let _ = crate::extensions::audit::append_audit_entry(
159 &crate::extensions::audit::new_audit_entry(
160 plugin_id,
161 provider_id,
162 model_id,
163 false,
164 0,
165 false,
166 "blocked",
167 Some("trust_disabled".to_string()),
168 ),
169 );
170 return Some(Err(format!(
171 "Provider '{}' is disabled by user trust settings",
172 provider_runtime_id
173 )
174 .into()));
175 }
176 let audit_plugin = plugin_id.to_string();
178 let audit_provider = provider_id.to_string();
179 let audit_model = model_id.to_string();
180 let tools_exposed = !tools_schema.is_empty();
181 let emit_audit =
182 |streamed: bool, outcome: &str, error_class: Option<&str>, tools_requested: u32| {
183 let _ = crate::extensions::audit::append_audit_entry(
184 &crate::extensions::audit::new_audit_entry(
185 audit_plugin.clone(),
186 audit_provider.clone(),
187 audit_model.clone(),
188 tools_exposed,
189 tools_requested,
190 streamed,
191 outcome,
192 error_class.map(|s| s.to_string()),
193 ),
194 );
195 };
196 if cancel.is_cancelled() {
197 emit_audit(false, "error", Some("canceled"), 0);
198 return Some(Err("operation canceled".into()));
199 }
200 let params = crate::extensions::runtime::process::ProviderCompleteParams {
201 provider_id: provider_id.to_string(),
202 model_id: model_id.to_string(),
203 model: model.to_string(),
204 messages: messages.to_vec(),
205 system_prompt: system_prompt.clone(),
206 tools: tools_schema.as_ref().clone(),
207 temperature,
208 max_tokens,
209 thinking_budget,
210 };
211 let has_active_tools = model_tool_use && !tools_schema.is_empty();
212 if streaming && !has_active_tools {
214 let (sink_tx, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<
215 crate::extensions::runtime::process::ProviderStreamEvent,
216 >();
217 let tx_clone = tx.clone();
218 let forwarder = tokio::spawn(async move {
219 use crate::extensions::runtime::process::ProviderStreamEvent;
220 while let Some(event) = sink_rx.recv().await {
221 match event {
222 ProviderStreamEvent::TextDelta { text } => {
223 let _ = tx_clone.send(crate::runtime::types::StreamEvent::Llm(
224 crate::runtime::types::LlmEvent::Text(text),
225 ));
226 }
227 ProviderStreamEvent::ToolUse { .. } => {
228 tracing::warn!("provider.stream tool_use event ignored (streaming tool-use not yet wired)");
229 }
230 _ => {}
232 }
233 }
234 });
235 let stream_fut = handler.provider_stream(params, sink_tx);
236 let result = tokio::select! {
237 biased;
238 _ = cancel.cancelled() => {
239 forwarder.abort();
240 emit_audit(true, "error", Some("canceled"), 0);
241 return Some(Err("operation canceled".into()));
242 }
243 res = stream_fut => res,
244 };
245 let _ = forwarder.await;
246 if cancel.is_cancelled() {
247 emit_audit(true, "error", Some("canceled"), 0);
248 return Some(Err("operation canceled".into()));
249 }
250 match result {
254 Ok(complete) => {
255 emit_audit(true, "ok", None, 0);
256 return Some(Ok(serde_json::json!({
257 "content": complete.content,
258 "stop_reason": complete.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
259 "usage": complete.usage.unwrap_or_else(|| serde_json::json!({}))
260 })));
261 }
262 Err(e) => {
263 emit_audit(true, "error", Some("provider_error"), 0);
264 return Some(Err(format!("extension provider: {e}").into()));
265 }
266 }
267 }
268 let result = if let Some(tools) = tools_shared {
269 let registry = tools.read().await;
270 crate::extensions::runtime::process::complete_provider_with_tools(
271 handler.clone(),
272 params,
273 ®istry,
274 &hook_bus,
275 || ToolContext {
276 channels: ToolChannels {
277 tx_delta: None,
278 tx_events: None,
279 },
280 capabilities: ToolCapabilities {
281 watcher_exit_path: None,
282 tool_register_tx: None,
283 session_manager: None,
284 subagent_registry: None,
285 event_queue: None,
286 secret_prompt: None,
287 },
288 limits: ToolLimits {
289 max_tool_output: 30000,
290 max_tool_buffer: 256 * 1024,
291 bash_timeout: 30,
292 bash_max_timeout: 300,
293 subagent_timeout: 300,
294 },
295 },
296 30000,
297 8,
298 )
299 .await
300 } else {
301 handler.provider_complete(params).await
302 };
303 if cancel.is_cancelled() {
304 emit_audit(false, "error", Some("canceled"), 0);
305 return Some(Err("operation canceled".into()));
306 }
307 match result {
312 Ok(complete) => {
313 let text = complete
314 .content
315 .iter()
316 .filter_map(|block| block.get("text").and_then(|v| v.as_str()))
317 .collect::<Vec<_>>()
318 .join("");
319 if !text.is_empty() {
320 let _ = tx.send(crate::runtime::types::StreamEvent::Llm(
321 crate::runtime::types::LlmEvent::Text(text),
322 ));
323 }
324 emit_audit(false, "ok", None, 0);
325 return Some(Ok(serde_json::json!({
326 "content": complete.content,
327 "stop_reason": complete.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
328 "usage": complete.usage.unwrap_or_else(|| serde_json::json!({}))
329 })));
330 }
331 Err(e) => {
332 emit_audit(false, "error", Some("provider_error"), 0);
333 return Some(Err(format!("extension provider: {e}").into()));
334 }
335 }
336 }
337 return Some(Err(format!(
338 "Extension provider model '{}' is not available",
339 model
340 )
341 .into()));
342 }
343
344 let provider_keys = crate::core::config::get_provider_keys();
345 match resolve_route(model, &provider_keys) {
346 Provider::OpenAi(cfg) => {
347 let result = stream::call_oai_stream_inner(
348 &cfg, client, tools_schema, system_prompt, messages, tx,
349 temperature, max_tokens, thinking_budget, cancel,
350 ).await;
351 Some(result)
352 }
353 Provider::Codex(cfg) => {
354 let result = stream::call_codex_stream_inner(
355 &cfg, client, tools_schema, system_prompt, messages, tx,
356 temperature, max_tokens, cancel, source, cache,
357 ).await;
358 Some(result)
359 }
360 Provider::Anthropic => None,
361 Provider::MissingKey(provider) => {
362 Some(Err(format!(
363 "No API key for '{}'. Set provider.{} in ~/.synaps-cli/config or the corresponding env var.",
364 provider, provider
365 ).into()))
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn resolves_openai_codex_without_requiring_eager_credentials() {
376 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
377 match resolve_route("openai-codex/gpt-5.1-codex-mini", &BTreeMap::new()) {
378 Provider::Codex(cfg) => {
379 assert_eq!(cfg.provider, "openai-codex");
380 assert_eq!(cfg.model, "gpt-5.1-codex-mini");
381 assert!(cfg.base_url.contains("chatgpt.com/backend-api"));
382 }
383 other => panic!("expected Codex route, got {other:?}"),
384 }
385 }
386
387 #[test]
388 fn set_extension_manager_for_routing_overwrites_previous_manager() {
389 clear_extension_manager_for_routing();
390 let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
391 crate::extensions::hooks::HookBus::new(),
392 ))));
393 let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
394 crate::extensions::hooks::HookBus::new(),
395 ))));
396
397 set_extension_manager_for_routing(first.clone());
398 assert!(Arc::ptr_eq(
399 &extension_manager_for_routing().unwrap(),
400 &first
401 ));
402
403 set_extension_manager_for_routing(second.clone());
404 assert!(Arc::ptr_eq(
405 &extension_manager_for_routing().unwrap(),
406 &second
407 ));
408
409 clear_extension_manager_for_routing();
410 assert!(extension_manager_for_routing().is_none());
411 }
412}