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 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/// Routing decision for a given model id.
53#[derive(Debug, Clone)]
54pub enum Provider {
55    /// Native Anthropic path (default, backward-compatible).
56    Anthropic,
57    /// OpenAI-compatible provider with a resolved config.
58    OpenAi(ProviderConfig),
59    /// ChatGPT subscription-backed Codex responses endpoint.
60    Codex(ProviderConfig),
61    /// Known provider prefix but no API key configured.
62    MissingKey(String),
63}
64
65/// Decide which backend a model should route to.
66///
67/// - `provider/model` shorthand where `provider` matches a known provider key → `OpenAi`
68/// - `claude-*` → `Anthropic`
69/// - anything else → `Anthropic` (backward compat)
70pub 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            // Known provider but no key
83            return Provider::MissingKey(prefix.to_string());
84        }
85    }
86    Provider::Anthropic
87}
88
89/// Try to route a request through an OpenAI-compatible provider.
90///
91/// Returns `Some(Ok(value))` if the model resolved to an OpenAI provider and the
92/// request completed (successfully or with error). Returns `None` if the model
93/// should be handled by the Anthropic path.
94///
95/// This is the single routing entry point — both streaming and non-streaming
96/// callers in `api.rs` use this instead of duplicating the routing logic.
97#[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: &[crate::SharedMessage],
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            // Per-provider trust gate: a disabled provider must not be invoked.
145            // The check runs before any IPC and we DO NOT silently fall back to
146            // built-in routing — instead return a clear routing error.
147            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            // Audit metadata captured up-front so each terminal branch can record an entry.
177            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                // Arc-shared (#128): refcount bumps, not a deep copy. Serde's
205                // `rc` feature serializes Arc<Value> transparently, so the
206                // wire shape crossing the extension boundary is unchanged.
207                messages: messages.to_vec(),
208                system_prompt: system_prompt.clone(),
209                tools: tools_schema.as_ref().clone(),
210                temperature,
211                max_tokens,
212                thinking_budget,
213            };
214            let has_active_tools = model_tool_use && !tools_schema.is_empty();
215            // Streaming path: forward TextDelta events as LlmEvent::Text deltas in real time.
216            if streaming && !has_active_tools {
217                let (sink_tx, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<
218                    crate::extensions::runtime::process::ProviderStreamEvent,
219                >();
220                let tx_clone = tx.clone();
221                let forwarder = tokio::spawn(async move {
222                    use crate::extensions::runtime::process::ProviderStreamEvent;
223                    while let Some(event) = sink_rx.recv().await {
224                        match event {
225                            ProviderStreamEvent::TextDelta { text } => {
226                                let _ = tx_clone.send(crate::runtime::types::StreamEvent::Llm(
227                                    crate::runtime::types::LlmEvent::Text(text),
228                                ));
229                            }
230                            ProviderStreamEvent::ToolUse { .. } => {
231                                tracing::warn!("provider.stream tool_use event ignored (streaming tool-use not yet wired)");
232                            }
233                            // Usage / Done / ThinkingDelta / Error are absorbed; final result aggregates them.
234                            _ => {}
235                        }
236                    }
237                });
238                let stream_fut = handler.provider_stream(params, sink_tx);
239                let result = tokio::select! {
240                    biased;
241                    _ = cancel.cancelled() => {
242                        forwarder.abort();
243                        emit_audit(true, "error", Some("canceled"), 0);
244                        return Some(Err("operation canceled".into()));
245                    }
246                    res = stream_fut => res,
247                };
248                let _ = forwarder.await;
249                if cancel.is_cancelled() {
250                    emit_audit(true, "error", Some("canceled"), 0);
251                    return Some(Err("operation canceled".into()));
252                }
253                // TODO(audit): tools_requested is reported as 0 for the streaming
254                // path until ProviderStreamEvent::ToolUse is wired through the
255                // forwarder; tool-use over streaming is not yet routed.
256                match result {
257                    Ok(complete) => {
258                        emit_audit(true, "ok", None, 0);
259                        return Some(Ok(serde_json::json!({
260                            "content": complete.content,
261                            "stop_reason": complete.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
262                            "usage": complete.usage.unwrap_or_else(|| serde_json::json!({}))
263                        })));
264                    }
265                    Err(e) => {
266                        emit_audit(true, "error", Some("provider_error"), 0);
267                        return Some(Err(format!("extension provider: {e}").into()));
268                    }
269                }
270            }
271            let result = if let Some(tools) = tools_shared {
272                let registry = tools.read().await;
273                crate::extensions::runtime::process::complete_provider_with_tools(
274                    handler.clone(),
275                    params,
276                    &registry,
277                    &hook_bus,
278                    || ToolContext {
279                        channels: ToolChannels {
280                            tx_delta: None,
281                            tx_events: None,
282                        },
283                        capabilities: ToolCapabilities {
284                            watcher_exit_path: None,
285                            tool_register_tx: None,
286                            session_manager: None,
287                            subagent_registry: None,
288                            event_queue: None,
289                            secret_prompt: None,
290                        },
291                        limits: ToolLimits {
292                            max_tool_output: 30000,
293                            max_tool_buffer: 256 * 1024,
294                            bash_timeout: 30,
295                            bash_max_timeout: 300,
296                            subagent_timeout: 300,
297                        },
298                    },
299                    30000,
300                    8,
301                )
302                .await
303            } else {
304                handler.provider_complete(params).await
305            };
306            if cancel.is_cancelled() {
307                emit_audit(false, "error", Some("canceled"), 0);
308                return Some(Err("operation canceled".into()));
309            }
310            // TODO(audit): tools_requested is reported as 0 here; the
311            // complete_provider_with_tools helper does not yet expose its
312            // observed tool-use iteration count. Wire that through when the
313            // helper grows a return-tuple or counter argument.
314            match result {
315                Ok(complete) => {
316                    let text = complete
317                        .content
318                        .iter()
319                        .filter_map(|block| block.get("text").and_then(|v| v.as_str()))
320                        .collect::<Vec<_>>()
321                        .join("");
322                    if !text.is_empty() {
323                        let _ = tx.send(crate::runtime::types::StreamEvent::Llm(
324                            crate::runtime::types::LlmEvent::Text(text),
325                        ));
326                    }
327                    emit_audit(false, "ok", None, 0);
328                    return Some(Ok(serde_json::json!({
329                        "content": complete.content,
330                        "stop_reason": complete.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
331                        "usage": complete.usage.unwrap_or_else(|| serde_json::json!({}))
332                    })));
333                }
334                Err(e) => {
335                    emit_audit(false, "error", Some("provider_error"), 0);
336                    return Some(Err(format!("extension provider: {e}").into()));
337                }
338            }
339        }
340        return Some(Err(format!(
341            "Extension provider model '{}' is not available",
342            model
343        )
344        .into()));
345    }
346
347    let provider_keys = crate::core::config::get_provider_keys();
348    match resolve_route(model, &provider_keys) {
349        Provider::OpenAi(cfg) => {
350            let result = stream::call_oai_stream_inner(
351                &cfg, client, tools_schema, system_prompt, messages, tx,
352                temperature, max_tokens, thinking_budget, cancel,
353            ).await;
354            Some(result)
355        }
356        Provider::Codex(cfg) => {
357            let result = stream::call_codex_stream_inner(
358                &cfg, client, tools_schema, system_prompt, messages, tx,
359                temperature, max_tokens, cancel, source, cache,
360            ).await;
361            Some(result)
362        }
363        Provider::Anthropic => None,
364        Provider::MissingKey(provider) => {
365            Some(Err(format!(
366                "No API key for '{}'. Set provider.{} in ~/.synaps-cli/config or the corresponding env var.",
367                provider, provider
368            ).into()))
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn resolves_openai_codex_without_requiring_eager_credentials() {
379        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
380        match resolve_route("openai-codex/gpt-5.1-codex-mini", &BTreeMap::new()) {
381            Provider::Codex(cfg) => {
382                assert_eq!(cfg.provider, "openai-codex");
383                assert_eq!(cfg.model, "gpt-5.1-codex-mini");
384                assert!(cfg.base_url.contains("chatgpt.com/backend-api"));
385            }
386            other => panic!("expected Codex route, got {other:?}"),
387        }
388    }
389
390    #[test]
391    fn set_extension_manager_for_routing_overwrites_previous_manager() {
392        clear_extension_manager_for_routing();
393        let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
394            crate::extensions::hooks::HookBus::new(),
395        ))));
396        let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
397            crate::extensions::hooks::HookBus::new(),
398        ))));
399
400        set_extension_manager_for_routing(first.clone());
401        assert!(Arc::ptr_eq(
402            &extension_manager_for_routing().unwrap(),
403            &first
404        ));
405
406        set_extension_manager_for_routing(second.clone());
407        assert!(Arc::ptr_eq(
408            &extension_manager_for_routing().unwrap(),
409            &second
410        ));
411
412        clear_extension_manager_for_routing();
413        assert!(extension_manager_for_routing().is_none());
414    }
415}