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
7pub mod catalog;
8pub(crate) mod extension_route;
9pub mod net;
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;
22
23pub use types::{
24    ChatMessage, ChatOptions, ChatRequest, FunctionCall, FunctionDefinition, OaiEvent,
25    ProviderConfig, StreamOptions, ToolCall, ToolChoice, ToolDefinition,
26};
27pub use wire::StreamDecoder;
28
29static EXTENSION_MANAGER: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<ExtensionManager>>>> =
30    std::sync::RwLock::new(None);
31
32pub fn set_extension_manager_for_routing(manager: Arc<tokio::sync::RwLock<ExtensionManager>>) {
33    *EXTENSION_MANAGER
34        .write()
35        .expect("extension manager routing lock poisoned") = Some(manager);
36}
37
38pub fn clear_extension_manager_for_routing() {
39    *EXTENSION_MANAGER
40        .write()
41        .expect("extension manager routing lock poisoned") = None;
42}
43
44pub fn extension_manager_for_routing() -> Option<Arc<tokio::sync::RwLock<ExtensionManager>>> {
45    EXTENSION_MANAGER
46        .read()
47        .expect("extension manager routing lock poisoned")
48        .clone()
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AuthPolicy {
53    OAuthAccessToken(crate::auth::OAuthProviderId),
54    BrokerProxy,
55}
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum WireProtocol {
58    AnthropicMessages,
59    OpenAiChatCompletions,
60    OpenAiResponses,
61    CodexResponses,
62    /// Google Gemini Code Assist v1internal:streamGenerateContent envelope.
63    /// Broker-proxied only.
64    GoogleGeminiCodeAssist,
65}
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ResolvedRoute {
68    pub endpoint: String,
69    pub model: String,
70    pub provider: String,
71    pub auth: AuthPolicy,
72    pub wire: WireProtocol,
73}
74
75/// Resolve typed credential and wire dimensions. Unknown explicit prefixes fail closed.
76pub fn resolve_route(model: &str) -> Option<ResolvedRoute> {
77    if let Some((prefix, rest)) = model.split_once('/') {
78        if prefix == "anthropic" && rest.starts_with("claude-") {
79            return Some(ResolvedRoute {
80                endpoint: "https://api.anthropic.com".into(),
81                model: rest.into(),
82                provider: prefix.into(),
83                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
84                wire: WireProtocol::AnthropicMessages,
85            });
86        }
87        if prefix == "xai-auth" && catalog::xai_model(rest).is_some() {
88            return Some(ResolvedRoute {
89                endpoint: "https://api.x.ai/v1".into(),
90                model: rest.into(),
91                provider: prefix.into(),
92                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai),
93                wire: WireProtocol::OpenAiResponses,
94            });
95        }
96        if prefix == "github-copilot" {
97            let descriptor = catalog::github_copilot_runtime_model(rest)?;
98            return Some(ResolvedRoute {
99                endpoint: catalog::MODELS_BASE_URL.into(),
100                model: rest.into(),
101                provider: prefix.into(),
102                auth: AuthPolicy::BrokerProxy,
103                wire: descriptor,
104            });
105        }
106        if prefix == "google-gemini" {
107            // Broker-proxied only: broker owns the Google OAuth access token
108            // and pins cloudcode-pa.googleapis.com. Wire IDs are limited to
109            // the conservative catalog.
110            catalog::google_gemini_model(rest)?;
111            return Some(ResolvedRoute {
112                endpoint: crate::auth::broker::GOOGLE_GEMINI_CODE_ASSIST_BASE_URL.into(),
113                model: rest.into(),
114                provider: prefix.into(),
115                auth: AuthPolicy::BrokerProxy,
116                wire: WireProtocol::GoogleGeminiCodeAssist,
117            });
118        }
119        if prefix == "openai-codex" {
120            let c = registry::resolve_codex_shorthand(model)?;
121            return Some(ResolvedRoute {
122                endpoint: c.base_url,
123                model: c.model,
124                provider: c.provider,
125                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::OpenAiCodex),
126                wire: WireProtocol::CodexResponses,
127            });
128        }
129        if prefix == "local" {
130            let c = registry::resolve_provider_model(prefix, rest)?;
131            return Some(ResolvedRoute {
132                endpoint: c.base_url,
133                model: c.model,
134                provider: c.provider,
135                auth: AuthPolicy::BrokerProxy,
136                wire: WireProtocol::OpenAiChatCompletions,
137            });
138        }
139        if let Some(spec) = registry::providers().iter().find(|s| s.key == prefix) {
140            return Some(ResolvedRoute {
141                endpoint: spec.base_url.into(),
142                model: rest.into(),
143                provider: prefix.into(),
144                auth: AuthPolicy::BrokerProxy,
145                wire: WireProtocol::OpenAiChatCompletions,
146            });
147        }
148        return None;
149    }
150    Some(ResolvedRoute {
151        endpoint: "https://api.anthropic.com".into(),
152        model: model.into(),
153        provider: "anthropic".into(),
154        auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
155        wire: WireProtocol::AnthropicMessages,
156    })
157}
158fn provider_config(r: &ResolvedRoute) -> ProviderConfig {
159    ProviderConfig {
160        base_url: r.endpoint.clone(),
161        model: r.model.clone(),
162        provider: r.provider.clone(),
163    }
164}
165
166/// Try to route a request through an OpenAI-compatible provider.
167///
168/// Returns `Some(Ok(value))` if the model resolved to an OpenAI provider and the
169/// request completed (successfully or with error). Returns `None` if the model
170/// should be handled by the Anthropic path.
171///
172/// This is the single routing entry point — both streaming and non-streaming
173/// callers in `api.rs` use this instead of duplicating the routing logic.
174/// `tool_session_id` scopes the execution gate for extension-provider
175/// interior tool loops (Task 16); `None` fails closed inside the route.
176#[allow(clippy::too_many_arguments)]
177pub async fn try_route(
178    model: &str,
179    client: &reqwest::Client,
180    tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
181    system_prompt: &Option<String>,
182    messages: &[crate::SharedMessage],
183    tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
184    temperature: Option<f32>,
185    max_tokens: Option<u32>,
186    thinking_budget: u32,
187    reasoning_level: agent_core::reasoning::ReasoningLevel,
188    cancel: &tokio_util::sync::CancellationToken,
189    source: &crate::auth::CredentialSource,
190    cache: &crate::auth::TokenCache,
191    max_retries: u32,
192    codex_request_role: catalog::CodexRequestRole,
193    tool_session_id: Option<&crate::tools::activation::SessionId>,
194    session_tool_set: Option<&crate::tools::activation::SharedSessionToolSet>,
195    trace: &crate::runtime::trace::TraceContext,
196    suppress_stream_deltas: bool,
197) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
198    if let Some((plugin_id, provider_id, model_id)) = ProviderRegistry::parse_model_id(model) {
199        if let Some(manager) = extension_manager_for_routing() {
200            // Extension-hosted provider: routing gates, audits, and the
201            // Task 10C transport trace live in `extension_route`.
202            return Some(
203                extension_route::route_extension_provider(
204                    manager,
205                    plugin_id,
206                    provider_id,
207                    model_id,
208                    model,
209                    tools_schema,
210                    system_prompt,
211                    messages,
212                    tx,
213                    temperature,
214                    max_tokens,
215                    thinking_budget,
216                    cancel,
217                    tool_session_id,
218                    session_tool_set,
219                    trace,
220                )
221                .await,
222            );
223        }
224        return Some(Err(format!(
225            "Extension provider model '{}' is not available",
226            model
227        )
228        .into()));
229    }
230
231    let Some(route) = resolve_route(model) else {
232        return Some(Err(
233            format!("Unknown provider route for model '{model}'").into()
234        ));
235    };
236    let cfg = provider_config(&route);
237    let broker = crate::auth::broker_from_source(source, cache, client.clone());
238    // Trace honesty (Task 10A): only a local (in-process) broker sends the
239    // exact bytes this process serialized (`ProxyRequest::body_bytes`); a
240    // remote broker daemon re-serializes upstream bodies out of process.
241    let exact_wire_bytes = !source.is_remote();
242    match route.wire {
243        WireProtocol::OpenAiChatCompletions => Some(
244            stream::call_oai_stream_inner(
245                &cfg,
246                &broker,
247                tools_schema,
248                system_prompt,
249                messages,
250                tx,
251                temperature,
252                max_tokens,
253                thinking_budget,
254                reasoning_level,
255                cancel,
256                trace,
257                exact_wire_bytes,
258                suppress_stream_deltas,
259            )
260            .await,
261        ),
262        WireProtocol::OpenAiResponses => {
263            if suppress_stream_deltas {
264                return Some(Err(
265                    "internal sync routing is unavailable for this streaming provider".into(),
266                ));
267            }
268            Some(
269                stream::call_xai_responses_stream_inner(
270                    &cfg,
271                    &broker,
272                    tools_schema,
273                    system_prompt,
274                    messages,
275                    tx,
276                    max_tokens,
277                    reasoning_level,
278                    cancel,
279                    trace,
280                    exact_wire_bytes,
281                )
282                .await,
283            )
284        }
285        WireProtocol::CodexResponses => {
286            if suppress_stream_deltas {
287                return Some(Err(
288                    "internal sync routing is unavailable for this streaming provider".into(),
289                ));
290            }
291            Some(
292                stream::call_codex_stream_inner(
293                    &cfg,
294                    client,
295                    &broker,
296                    tools_schema,
297                    system_prompt,
298                    messages,
299                    tx,
300                    temperature,
301                    max_tokens,
302                    reasoning_level,
303                    codex_request_role,
304                    cancel,
305                    // Codex transport rides out chatgpt.com edge bursts with the
306                    // same persistent posture as Anthropic OAuth overloads (10
307                    // retries), not the generic three-attempt budget.
308                    stream::codex_retry_budget(max_retries),
309                    trace,
310                )
311                .await,
312            )
313        }
314        WireProtocol::AnthropicMessages => None,
315        WireProtocol::GoogleGeminiCodeAssist => {
316            if suppress_stream_deltas {
317                return Some(Err(
318                    "internal sync routing is unavailable for this streaming provider".into(),
319                ));
320            }
321            Some(
322                crate::runtime::google_gemini::runtime::call_google_gemini_stream_inner(
323                    &cfg,
324                    &broker,
325                    tools_schema,
326                    system_prompt,
327                    messages,
328                    tx,
329                    cancel,
330                    trace,
331                    exact_wire_bytes,
332                )
333                .await,
334            )
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn anthropic_identity_is_explicit_while_legacy_bare_ids_still_route() {
345        for id in ["claude-sonnet-4-6", "claude-opus-4-7"] {
346            let qualified = resolve_route(&format!("anthropic/{id}"))
347                .expect("qualified Anthropic model must route");
348            assert_eq!(qualified.provider, "anthropic");
349            assert_eq!(qualified.model, id);
350            assert_eq!(qualified.wire, WireProtocol::AnthropicMessages);
351
352            let legacy = resolve_route(id).expect("legacy bare Claude model must route");
353            assert_eq!(legacy.provider, "anthropic");
354            assert_eq!(legacy.model, id);
355        }
356        assert_eq!(
357            resolve_route("github-copilot/claude-sonnet-4.6")
358                .unwrap()
359                .provider,
360            "github-copilot"
361        );
362    }
363
364    #[test]
365    fn brokered_provider_routing_is_typed_and_fail_closed() {
366        for descriptor in catalog::XAI_TEXT_MODELS {
367            let runtime_id = format!("xai-auth/{}", descriptor.id);
368            let xai = resolve_route(&runtime_id).unwrap();
369            assert_eq!(xai.model, descriptor.id);
370            assert_eq!(xai.provider, "xai-auth");
371            assert_eq!(xai.endpoint, "https://api.x.ai/v1");
372            assert_eq!(
373                xai.auth,
374                AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai)
375            );
376            assert_eq!(xai.wire, WireProtocol::OpenAiResponses);
377        }
378        let static_route = resolve_route("groq/llama-3.3-70b-versatile").unwrap();
379        assert_eq!(static_route.auth, AuthPolicy::BrokerProxy);
380        assert_eq!(static_route.wire, WireProtocol::OpenAiChatCompletions);
381
382        let copilot_chat = resolve_route("github-copilot/claude-sonnet-4.6").unwrap();
383        assert_eq!(copilot_chat.auth, AuthPolicy::BrokerProxy);
384        assert_eq!(copilot_chat.wire, WireProtocol::OpenAiChatCompletions);
385        let copilot_responses = resolve_route("github-copilot/gpt-5.3-codex").unwrap();
386        assert_eq!(copilot_responses.auth, AuthPolicy::BrokerProxy);
387        assert_eq!(copilot_responses.wire, WireProtocol::OpenAiResponses);
388        assert!(resolve_route("github-copilot/unverified-model").is_none());
389        assert!(resolve_route("unknown-provider/model").is_none());
390        assert!(resolve_route("xai-auth/not-a-real-model").is_none());
391        assert!(resolve_route("xai-auth/grok-build-0.1").is_none());
392        assert!(resolve_route("xai-auth/grok-build-latest").is_none());
393        assert!(resolve_route("xai-auth/grok-imagine-image").is_none());
394
395        // Every verified concrete text/tool wire ID in the catalog must resolve.
396        for descriptor in catalog::GOOGLE_GEMINI_TEXT_MODELS {
397            let route = resolve_route(&format!("google-gemini/{}", descriptor.id)).unwrap();
398            assert_eq!(route.provider, "google-gemini");
399            assert_eq!(route.model, descriptor.id);
400            assert_eq!(route.endpoint, "https://cloudcode-pa.googleapis.com");
401            assert_eq!(route.auth, AuthPolicy::BrokerProxy);
402            assert_eq!(route.wire, WireProtocol::GoogleGeminiCodeAssist);
403        }
404        // Aliases, unsupported media/embedding models, and family prefixes fail closed.
405        for banned in [
406            "auto-gemini-2.5",
407            "auto-gemini-3",
408            "text-embedding-004",
409            "gemini-2.5-image",
410            "gemini-2.5", // family prefix is not a wire id
411        ] {
412            assert!(
413                resolve_route(&format!("google-gemini/{banned}")).is_none(),
414                "{banned} must not resolve"
415            );
416        }
417    }
418
419    /// Regression: `gemini-pro-latest` must NOT resolve — it is a public
420    /// Gemini API alias that Code Assist rejects with 404 NOT_FOUND. Routing
421    /// fails closed instead of emitting a doomed upstream request.
422    #[test]
423    fn does_not_resolve_public_api_alias_gemini_pro_latest() {
424        assert!(
425            resolve_route("google-gemini/gemini-pro-latest").is_none(),
426            "gemini-pro-latest is not a Code Assist wire ID (404 upstream)"
427        );
428    }
429
430    #[test]
431    fn resolves_openai_codex_without_requiring_eager_credentials() {
432        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
433        let route = resolve_route("openai-codex/gpt-5.1-codex-mini").unwrap();
434        assert_eq!(route.provider, "openai-codex");
435        assert_eq!(route.wire, WireProtocol::CodexResponses);
436    }
437
438    /// Regression: models under the `google-gemini/*` prefix must route through
439    /// `try_route` (i.e. the OpenAI-runtime dispatcher must return `Some(_)` so
440    /// the caller invokes the Gemini stream rather than falling through to the
441    /// Anthropic path and reporting a misleading 401. Before this fix,
442    /// `WireProtocol::GoogleGeminiCodeAssist => None` made `try_route` return
443    /// `None`.
444    ///
445    /// We drive `try_route` through the `Remote` credential source pointed at a
446    /// closed loopback port so the broker request errors out fast without
447    /// touching the developer's real auth.json or the network. The outcome we
448    /// assert is only that the routing dispatcher returned `Some(_)` — the
449    /// specific broker error is irrelevant.
450    #[tokio::test]
451    async fn try_route_google_gemini_is_not_none_and_does_not_fall_through_to_anthropic() {
452        let client = reqwest::Client::builder()
453            .connect_timeout(std::time::Duration::from_secs(2))
454            .build()
455            .expect("client");
456        // 127.0.0.1:1 is a reserved port; on Linux connect(2) returns
457        // ECONNREFUSED immediately so the broker call fails fast.
458        let source = crate::auth::CredentialSource::Remote {
459            endpoint: "http://127.0.0.1:1".into(),
460            machine_token: "not-a-real-token".into(),
461        };
462        let cache = crate::auth::TokenCache::new();
463        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
464        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
465        let cancel = tokio_util::sync::CancellationToken::new();
466
467        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
468            "role": "user",
469            "content": "hi"
470        }))];
471
472        let trace = crate::runtime::trace::TraceContext::disabled();
473        let fut = try_route(
474            "google-gemini/gemini-2.5-pro",
475            &client,
476            &tools_schema,
477            &None,
478            &messages,
479            &tx,
480            None,
481            None,
482            0,
483            agent_core::reasoning::ReasoningLevel::Adaptive,
484            &cancel,
485            &source,
486            &cache,
487            0,
488            catalog::CodexRequestRole::Foreground,
489            None,
490            None,
491            &trace,
492            false,
493        );
494        let result = tokio::time::timeout(std::time::Duration::from_secs(10), fut)
495            .await
496            .expect("try_route completed within budget");
497
498        assert!(
499            result.is_some(),
500            "google-gemini must route through try_route (was None → fell through to Anthropic)"
501        );
502    }
503
504    /// Headless harness (no live credentials): rejected xAI reasoning
505    /// combinations must fail BEFORE any credential or network access on the
506    /// Responses path. The Remote credential source points at a closed
507    /// loopback port — if validation ran after broker access this would
508    /// surface a connection error instead of the capability message.
509    #[tokio::test]
510    async fn xai_off_is_rejected_pre_network_through_try_route() {
511        let client = reqwest::Client::builder()
512            .connect_timeout(std::time::Duration::from_secs(2))
513            .build()
514            .expect("client");
515        let source = crate::auth::CredentialSource::Remote {
516            endpoint: "http://127.0.0.1:1".into(),
517            machine_token: "not-a-real-token".into(),
518        };
519        let cache = crate::auth::TokenCache::new();
520        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
521        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
522        let cancel = tokio_util::sync::CancellationToken::new();
523        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
524            "role": "user",
525            "content": "hi"
526        }))];
527
528        for (level, needle) in [
529            (
530                agent_core::reasoning::ReasoningLevel::Off,
531                "cannot be disabled",
532            ),
533            (
534                agent_core::reasoning::ReasoningLevel::XHigh,
535                "not supported",
536            ),
537            (
538                agent_core::reasoning::ReasoningLevel::Ultra,
539                "not supported",
540            ),
541        ] {
542            let result = try_route(
543                "xai-auth/grok-4.5",
544                &client,
545                &tools_schema,
546                &None,
547                &messages,
548                &tx,
549                None,
550                None,
551                0,
552                level,
553                &cancel,
554                &source,
555                &cache,
556                0,
557                catalog::CodexRequestRole::Foreground,
558                None,
559                None,
560                &crate::runtime::trace::TraceContext::disabled(),
561                false,
562            )
563            .await
564            .expect("xai-auth must route through try_route");
565            let err = result.expect_err("rejected level must error");
566            assert!(
567                err.to_string().contains(needle),
568                "{level}: pre-network capability rejection expected, got: {err}"
569            );
570        }
571    }
572
573    #[test]
574    fn set_extension_manager_for_routing_overwrites_previous_manager() {
575        clear_extension_manager_for_routing();
576        let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
577            crate::extensions::hooks::HookBus::new(),
578        ))));
579        let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
580            crate::extensions::hooks::HookBus::new(),
581        ))));
582
583        set_extension_manager_for_routing(first.clone());
584        assert!(Arc::ptr_eq(
585            &extension_manager_for_routing().unwrap(),
586            &first
587        ));
588
589        set_extension_manager_for_routing(second.clone());
590        assert!(Arc::ptr_eq(
591            &extension_manager_for_routing().unwrap(),
592            &second
593        ));
594
595        clear_extension_manager_for_routing();
596        assert!(extension_manager_for_routing().is_none());
597    }
598}