Skip to main content

agent_engine/runtime/openai/
mod.rs

1//! OpenAI-compatible provider engine.
2//!
3//! Ported from the `openai-runtime` prototype. Translates between Anthropic-shaped
4//! messages/tools/content-blocks (the internal synaps representation) and
5//! OpenAI `chat/completions` SSE wire.
6
7use 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/// Routing decision for a given model id.
48#[derive(Debug, Clone)]
49pub enum Provider {
50    /// Native Anthropic path (default, backward-compatible).
51    Anthropic,
52    /// OpenAI-compatible provider with a resolved config.
53    OpenAi(ProviderConfig),
54    /// ChatGPT subscription-backed Codex responses endpoint.
55    Codex(ProviderConfig),
56    /// Known provider prefix but no API key configured.
57    MissingKey(String),
58}
59
60/// Decide which backend a model should route to.
61///
62/// - `provider/model` shorthand where `provider` matches a known provider key → `OpenAi`
63/// - `claude-*` → `Anthropic`
64/// - anything else → `Anthropic` (backward compat)
65pub 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            // Known provider but no key
78            return Provider::MissingKey(prefix.to_string());
79        }
80    }
81    Provider::Anthropic
82}
83
84/// Try to route a request through an OpenAI-compatible provider.
85///
86/// Returns `Some(Ok(value))` if the model resolved to an OpenAI provider and the
87/// request completed (successfully or with error). Returns `None` if the model
88/// should be handled by the Anthropic path.
89///
90/// This is the single routing entry point — both streaming and non-streaming
91/// callers in `api.rs` use this instead of duplicating the routing logic.
92#[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            // Per-provider trust gate: a disabled provider must not be invoked.
130            // The check runs before any IPC and we DO NOT silently fall back to
131            // built-in routing — instead return a clear routing error.
132            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            // Audit metadata captured up-front so each terminal branch can record an entry.
160            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            // Streaming path: forward TextDelta events as LlmEvent::Text deltas in real time.
195            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                            // Usage / Done / ThinkingDelta / Error are absorbed; final result aggregates them.
211                            _ => {}
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                // TODO(audit): tools_requested is reported as 0 for the streaming
231                // path until ProviderStreamEvent::ToolUse is wired through the
232                // forwarder; tool-use over streaming is not yet routed.
233                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                    &registry,
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            // TODO(audit): tools_requested is reported as 0 here; the
286            // complete_provider_with_tools helper does not yet expose its
287            // observed tool-use iteration count. Wire that through when the
288            // helper grows a return-tuple or counter argument.
289            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}