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