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