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(crate) mod account_routing;
8pub mod catalog;
9pub(crate) mod codex_wire;
10pub(crate) mod extension_route;
11pub mod net;
12pub mod ping;
13pub mod reasoning;
14pub mod registry;
15pub mod stream;
16pub mod translate;
17pub mod types;
18pub mod wire;
19
20use std::sync::Arc;
21
22use crate::extensions::manager::ExtensionManager;
23use crate::extensions::providers::ProviderRegistry;
24
25pub use types::{
26    ChatMessage, ChatOptions, ChatRequest, FunctionCall, FunctionDefinition, OaiEvent,
27    ProviderConfig, StreamOptions, ToolCall, ToolChoice, ToolDefinition,
28};
29pub use wire::StreamDecoder;
30
31static EXTENSION_MANAGER: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<ExtensionManager>>>> =
32    std::sync::RwLock::new(None);
33
34pub fn set_extension_manager_for_routing(manager: Arc<tokio::sync::RwLock<ExtensionManager>>) {
35    *EXTENSION_MANAGER
36        .write()
37        .expect("extension manager routing lock poisoned") = Some(manager);
38}
39
40pub fn clear_extension_manager_for_routing() {
41    *EXTENSION_MANAGER
42        .write()
43        .expect("extension manager routing lock poisoned") = None;
44}
45
46pub fn extension_manager_for_routing() -> Option<Arc<tokio::sync::RwLock<ExtensionManager>>> {
47    EXTENSION_MANAGER
48        .read()
49        .expect("extension manager routing lock poisoned")
50        .clone()
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum AuthPolicy {
55    OAuthAccessToken(crate::auth::OAuthProviderId),
56    BrokerProxy,
57}
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum WireProtocol {
60    AnthropicMessages,
61    OpenAiChatCompletions,
62    OpenAiResponses,
63    CodexResponses,
64    /// Google Gemini Code Assist v1internal:streamGenerateContent envelope.
65    /// Broker-proxied only.
66    GoogleGeminiCodeAssist,
67}
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ResolvedRoute {
70    pub endpoint: String,
71    pub model: String,
72    pub provider: String,
73    pub auth: AuthPolicy,
74    pub wire: WireProtocol,
75}
76
77/// Resolve typed credential and wire dimensions. Unknown explicit prefixes fail closed.
78pub fn resolve_route(model: &str) -> Option<ResolvedRoute> {
79    if let Some((prefix, rest)) = model.split_once('/') {
80        if prefix == "anthropic" && rest.starts_with("claude-") {
81            return Some(ResolvedRoute {
82                endpoint: "https://api.anthropic.com".into(),
83                model: rest.into(),
84                provider: prefix.into(),
85                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
86                wire: WireProtocol::AnthropicMessages,
87            });
88        }
89        if prefix == "xai-auth" && catalog::xai_model(rest).is_some() {
90            return Some(ResolvedRoute {
91                endpoint: "https://api.x.ai/v1".into(),
92                model: rest.into(),
93                provider: prefix.into(),
94                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai),
95                wire: WireProtocol::OpenAiResponses,
96            });
97        }
98        if prefix == "github-copilot" {
99            let descriptor = catalog::github_copilot_runtime_model(rest)?;
100            return Some(ResolvedRoute {
101                endpoint: catalog::MODELS_BASE_URL.into(),
102                model: rest.into(),
103                provider: prefix.into(),
104                auth: AuthPolicy::BrokerProxy,
105                wire: descriptor,
106            });
107        }
108        if prefix == "google-gemini" {
109            // Broker-proxied only: broker owns the Google OAuth access token
110            // and pins cloudcode-pa.googleapis.com. Wire IDs are limited to
111            // the conservative catalog.
112            catalog::google_gemini_model(rest)?;
113            return Some(ResolvedRoute {
114                endpoint: crate::auth::broker::GOOGLE_GEMINI_CODE_ASSIST_BASE_URL.into(),
115                model: rest.into(),
116                provider: prefix.into(),
117                auth: AuthPolicy::BrokerProxy,
118                wire: WireProtocol::GoogleGeminiCodeAssist,
119            });
120        }
121        if prefix == "openai-codex" {
122            let c = registry::resolve_codex_shorthand(model)?;
123            return Some(ResolvedRoute {
124                endpoint: c.base_url,
125                model: c.model,
126                provider: c.provider,
127                auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::OpenAiCodex),
128                wire: WireProtocol::CodexResponses,
129            });
130        }
131        if prefix == "kimi-code" {
132            // Broker-proxied managed Kimi Code endpoint: the broker owns the
133            // short-lived OAuth access token (and its rotating refresh token)
134            // and pins api.kimi.com/coding/v1. Wire ids are limited to the
135            // conservative managed catalog — unknown ids fail closed.
136            catalog::kimi_code_model(rest)?;
137            return Some(ResolvedRoute {
138                endpoint: crate::auth::kimi_code::API_BASE_URL.into(),
139                model: rest.into(),
140                provider: prefix.into(),
141                auth: AuthPolicy::BrokerProxy,
142                wire: WireProtocol::OpenAiChatCompletions,
143            });
144        }
145        if prefix == "muse" {
146            // Broker-proxied Meta Muse: the broker resolves the Model API key
147            // (env override or minted from the Meta login) and pins
148            // api.meta.ai. Server-driven catalog → registry precedent: any
149            // safe-shaped id routes and Meta decides existence. Routing never
150            // depends on the process-local catalog cache (daemon/resume).
151            if !catalog::muse_routable_id(rest) {
152                return None;
153            }
154            return Some(ResolvedRoute {
155                endpoint: crate::auth::muse::API_BASE_URL.into(),
156                model: rest.into(),
157                provider: prefix.into(),
158                auth: AuthPolicy::BrokerProxy,
159                wire: WireProtocol::OpenAiResponses,
160            });
161        }
162        if prefix == "local" {
163            let c = registry::resolve_provider_model(prefix, rest)?;
164            return Some(ResolvedRoute {
165                endpoint: c.base_url,
166                model: c.model,
167                provider: c.provider,
168                auth: AuthPolicy::BrokerProxy,
169                wire: WireProtocol::OpenAiChatCompletions,
170            });
171        }
172        if let Some(spec) = registry::providers().iter().find(|s| s.key == prefix) {
173            return Some(ResolvedRoute {
174                endpoint: spec.base_url.into(),
175                model: rest.into(),
176                provider: prefix.into(),
177                auth: AuthPolicy::BrokerProxy,
178                wire: WireProtocol::OpenAiChatCompletions,
179            });
180        }
181        return None;
182    }
183    Some(ResolvedRoute {
184        endpoint: "https://api.anthropic.com".into(),
185        model: model.into(),
186        provider: "anthropic".into(),
187        auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
188        wire: WireProtocol::AnthropicMessages,
189    })
190}
191fn provider_config(r: &ResolvedRoute) -> ProviderConfig {
192    ProviderConfig {
193        base_url: r.endpoint.clone(),
194        model: r.model.clone(),
195        provider: r.provider.clone(),
196    }
197}
198
199/// Try to route a request through an OpenAI-compatible provider.
200///
201/// Returns `Some(Ok(value))` if the model resolved to an OpenAI provider and the
202/// request completed (successfully or with error). Returns `None` if the model
203/// should be handled by the Anthropic path.
204///
205/// This is the single routing entry point — both streaming and non-streaming
206/// callers in `api.rs` use this instead of duplicating the routing logic.
207/// `tool_session_id` scopes the execution gate for extension-provider
208/// interior tool loops (Task 16); `None` fails closed inside the route.
209#[allow(clippy::too_many_arguments)]
210pub async fn try_route(
211    model: &str,
212    client: &reqwest::Client,
213    tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
214    system_prompt: &Option<String>,
215    messages: &[crate::SharedMessage],
216    tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
217    temperature: Option<f32>,
218    max_tokens: Option<u32>,
219    thinking_budget: u32,
220    reasoning_level: agent_core::reasoning::ReasoningLevel,
221    cancel: &tokio_util::sync::CancellationToken,
222    source: &crate::auth::CredentialSource,
223    cache: &crate::auth::TokenCache,
224    max_retries: u32,
225    codex_request_role: catalog::CodexRequestRole,
226    tool_session_id: Option<&crate::tools::activation::SessionId>,
227    session_tool_set: Option<&crate::tools::activation::SharedSessionToolSet>,
228    trace: &crate::runtime::trace::TraceContext,
229    suppress_stream_deltas: bool,
230) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
231    try_route_with_memory_backend(
232        model,
233        client,
234        tools_schema,
235        system_prompt,
236        messages,
237        tx,
238        temperature,
239        max_tokens,
240        thinking_budget,
241        reasoning_level,
242        cancel,
243        source,
244        cache,
245        max_retries,
246        codex_request_role,
247        tool_session_id,
248        session_tool_set,
249        None,
250        trace,
251        suppress_stream_deltas,
252    )
253    .await
254}
255
256/// The host-key-free history every non-Anthropic route sends, or `None`
257/// when there is nothing to strip or the model takes the native Anthropic
258/// transport (which strips while serializing its body).
259pub(crate) fn routed_wire_messages(
260    model: &str,
261    messages: &[crate::SharedMessage],
262) -> Option<Vec<crate::SharedMessage>> {
263    let native_anthropic = ProviderRegistry::parse_model_id(model).is_none()
264        && resolve_route(model).is_some_and(|r| r.wire == WireProtocol::AnthropicMessages);
265    if native_anthropic {
266        return None;
267    }
268    super::continuation::wire_messages(messages)
269}
270
271/// Runtime route carrying what the turn's tool calls are given, for the tool
272/// loop of an extension-provided model (`tool_context`; see
273/// [`crate::runtime::api::ApiOptions`]). The public compatibility facade
274/// above has no runtime capability and cannot mint one.
275#[allow(clippy::too_many_arguments)]
276pub(crate) async fn try_route_with_memory_backend(
277    model: &str,
278    client: &reqwest::Client,
279    tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
280    system_prompt: &Option<String>,
281    messages: &[crate::SharedMessage],
282    tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
283    temperature: Option<f32>,
284    max_tokens: Option<u32>,
285    thinking_budget: u32,
286    reasoning_level: agent_core::reasoning::ReasoningLevel,
287    cancel: &tokio_util::sync::CancellationToken,
288    source: &crate::auth::CredentialSource,
289    cache: &crate::auth::TokenCache,
290    max_retries: u32,
291    codex_request_role: catalog::CodexRequestRole,
292    tool_session_id: Option<&crate::tools::activation::SessionId>,
293    session_tool_set: Option<&crate::tools::activation::SharedSessionToolSet>,
294    tool_context: Option<&crate::tools::TurnToolContext>,
295    trace: &crate::runtime::trace::TraceContext,
296    suppress_stream_deltas: bool,
297) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
298    // Muse capability evidence (exact effort set, modalities) lives in the
299    // process-local cache; load it here via the turn's broker when this
300    // process has none for the model, BEFORE body validation consults it.
301    if model.starts_with("muse/") {
302        let broker = crate::auth::broker_from_source(source, cache, client.clone());
303        catalog::ensure_muse_catalog_for(model, &broker).await;
304    }
305    if let Err(error) = super::attachments::validate_history_for_request(model, messages) {
306        return Some(Err(error.into()));
307    }
308    // Host `_synaps_*` keys never reach any of these transports (some
309    // forward the history verbatim). Only the native Anthropic route, which
310    // returns `None` below without reading `messages`, skips the copy.
311    let wire = routed_wire_messages(model, messages);
312    let messages = wire.as_deref().unwrap_or(messages);
313    if let Some((plugin_id, provider_id, model_id)) = ProviderRegistry::parse_model_id(model) {
314        if let Some(manager) = extension_manager_for_routing() {
315            // Extension-hosted provider: routing gates, audits, and the
316            // Task 10C transport trace live in `extension_route`.
317            return Some(
318                extension_route::route_extension_provider(
319                    manager,
320                    plugin_id,
321                    provider_id,
322                    model_id,
323                    model,
324                    tools_schema,
325                    system_prompt,
326                    messages,
327                    tx,
328                    temperature,
329                    max_tokens,
330                    thinking_budget,
331                    cancel,
332                    tool_session_id,
333                    session_tool_set,
334                    tool_context,
335                    trace,
336                )
337                .await,
338            );
339        }
340        return Some(Err(format!(
341            "Extension provider model '{}' is not available",
342            model
343        )
344        .into()));
345    }
346
347    let Some(route) = resolve_route(model) else {
348        return Some(Err(
349            format!("Unknown provider route for model '{model}'").into()
350        ));
351    };
352    let cfg = provider_config(&route);
353    let broker = crate::auth::broker_from_source(source, cache, client.clone());
354    // Trace honesty (Task 10A): only a local (in-process) broker sends the
355    // exact bytes this process serialized (`ProxyRequest::body_bytes`); a
356    // remote broker daemon re-serializes upstream bodies out of process.
357    let exact_wire_bytes = !source.is_remote();
358    match route.wire {
359        WireProtocol::OpenAiChatCompletions => Some(
360            stream::call_oai_stream_inner(
361                &cfg,
362                &broker,
363                tools_schema,
364                system_prompt,
365                messages,
366                tx,
367                temperature,
368                max_tokens,
369                thinking_budget,
370                reasoning_level,
371                cancel,
372                trace,
373                exact_wire_bytes,
374                suppress_stream_deltas,
375                max_retries,
376            )
377            .await,
378        ),
379        WireProtocol::OpenAiResponses => {
380            if suppress_stream_deltas {
381                return Some(Err(
382                    "internal sync routing is unavailable for this streaming provider".into(),
383                ));
384            }
385            // Same wire, per-provider body/identity: Meta Muse mirrors the
386            // official Muse Code request shape; everything else is xAI.
387            Some(
388                stream::call_responses_route_inner(
389                    &route.provider,
390                    &cfg,
391                    &broker,
392                    tools_schema,
393                    system_prompt,
394                    messages,
395                    tx,
396                    max_tokens,
397                    reasoning_level,
398                    cancel,
399                    // In-stream capacity errors (HTTP 200 + lone `error`
400                    // event) are transient; re-send identical bytes with
401                    // the configured retry budget.
402                    max_retries,
403                    trace,
404                    exact_wire_bytes,
405                )
406                .await,
407            )
408        }
409        WireProtocol::CodexResponses => {
410            if suppress_stream_deltas {
411                return Some(Err(
412                    "internal sync routing is unavailable for this streaming provider".into(),
413                ));
414            }
415            Some(
416                stream::call_codex_stream_inner(
417                    &cfg,
418                    client,
419                    &broker,
420                    tools_schema,
421                    system_prompt,
422                    messages,
423                    tx,
424                    temperature,
425                    max_tokens,
426                    reasoning_level,
427                    codex_request_role,
428                    cancel,
429                    // Codex transport rides out chatgpt.com edge bursts with the
430                    // same persistent posture as Anthropic OAuth overloads (10
431                    // retries), not the generic three-attempt budget.
432                    stream::codex_retry_budget(max_retries),
433                    trace,
434                )
435                .await,
436            )
437        }
438        WireProtocol::AnthropicMessages => None,
439        WireProtocol::GoogleGeminiCodeAssist => {
440            if suppress_stream_deltas {
441                return Some(Err(
442                    "internal sync routing is unavailable for this streaming provider".into(),
443                ));
444            }
445            Some(
446                crate::runtime::google_gemini::runtime::call_google_gemini_stream_inner(
447                    &cfg,
448                    &broker,
449                    tools_schema,
450                    system_prompt,
451                    messages,
452                    tx,
453                    cancel,
454                    trace,
455                    exact_wire_bytes,
456                )
457                .await,
458            )
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn anthropic_identity_is_explicit_while_legacy_bare_ids_still_route() {
469        for id in ["claude-sonnet-4-6", "claude-opus-4-7"] {
470            let qualified = resolve_route(&format!("anthropic/{id}"))
471                .expect("qualified Anthropic model must route");
472            assert_eq!(qualified.provider, "anthropic");
473            assert_eq!(qualified.model, id);
474            assert_eq!(qualified.wire, WireProtocol::AnthropicMessages);
475
476            let legacy = resolve_route(id).expect("legacy bare Claude model must route");
477            assert_eq!(legacy.provider, "anthropic");
478            assert_eq!(legacy.model, id);
479        }
480        assert_eq!(
481            resolve_route("github-copilot/claude-sonnet-4.6")
482                .unwrap()
483                .provider,
484            "github-copilot"
485        );
486    }
487
488    #[test]
489    fn brokered_provider_routing_is_typed_and_fail_closed() {
490        for descriptor in catalog::XAI_TEXT_MODELS {
491            let runtime_id = format!("xai-auth/{}", descriptor.id);
492            let xai = resolve_route(&runtime_id).unwrap();
493            assert_eq!(xai.model, descriptor.id);
494            assert_eq!(xai.provider, "xai-auth");
495            assert_eq!(xai.endpoint, "https://api.x.ai/v1");
496            assert_eq!(
497                xai.auth,
498                AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai)
499            );
500            assert_eq!(xai.wire, WireProtocol::OpenAiResponses);
501        }
502        let static_route = resolve_route("groq/llama-3.3-70b-versatile").unwrap();
503        assert_eq!(static_route.auth, AuthPolicy::BrokerProxy);
504        assert_eq!(static_route.wire, WireProtocol::OpenAiChatCompletions);
505
506        let copilot_chat = resolve_route("github-copilot/claude-sonnet-4.6").unwrap();
507        assert_eq!(copilot_chat.auth, AuthPolicy::BrokerProxy);
508        assert_eq!(copilot_chat.wire, WireProtocol::OpenAiChatCompletions);
509        let copilot_responses = resolve_route("github-copilot/gpt-5.3-codex").unwrap();
510        assert_eq!(copilot_responses.auth, AuthPolicy::BrokerProxy);
511        assert_eq!(copilot_responses.wire, WireProtocol::OpenAiResponses);
512        assert!(resolve_route("github-copilot/unverified-model").is_none());
513        assert!(resolve_route("unknown-provider/model").is_none());
514        assert!(resolve_route("xai-auth/not-a-real-model").is_none());
515        assert!(resolve_route("xai-auth/grok-build-0.1").is_none());
516        assert!(resolve_route("xai-auth/grok-build-latest").is_none());
517        assert!(resolve_route("xai-auth/grok-imagine-image").is_none());
518
519        // Every verified concrete text/tool wire ID in the catalog must resolve.
520        for descriptor in catalog::GOOGLE_GEMINI_TEXT_MODELS {
521            let route = resolve_route(&format!("google-gemini/{}", descriptor.id)).unwrap();
522            assert_eq!(route.provider, "google-gemini");
523            assert_eq!(route.model, descriptor.id);
524            assert_eq!(route.endpoint, "https://cloudcode-pa.googleapis.com");
525            assert_eq!(route.auth, AuthPolicy::BrokerProxy);
526            assert_eq!(route.wire, WireProtocol::GoogleGeminiCodeAssist);
527        }
528        // Aliases, unsupported media/embedding models, and family prefixes fail closed.
529        for banned in [
530            "auto-gemini-2.5",
531            "auto-gemini-3",
532            "text-embedding-004",
533            "gemini-2.5-image",
534            "gemini-2.5", // family prefix is not a wire id
535        ] {
536            assert!(
537                resolve_route(&format!("google-gemini/{banned}")).is_none(),
538                "{banned} must not resolve"
539            );
540        }
541    }
542
543    /// Regression: `gemini-pro-latest` must NOT resolve — it is a public
544    /// Gemini API alias that Code Assist rejects with 404 NOT_FOUND. Routing
545    /// fails closed instead of emitting a doomed upstream request.
546    #[test]
547    fn does_not_resolve_public_api_alias_gemini_pro_latest() {
548        assert!(
549            resolve_route("google-gemini/gemini-pro-latest").is_none(),
550            "gemini-pro-latest is not a Code Assist wire ID (404 upstream)"
551        );
552    }
553
554    #[test]
555    fn muse_routing_is_broker_proxied_responses_and_fail_closed() {
556        let route = resolve_route("muse/muse-wonder-1.0").expect("documented seed routes");
557        assert_eq!(route.provider, "muse");
558        assert_eq!(route.model, "muse-wonder-1.0");
559        assert_eq!(route.endpoint, crate::auth::muse::API_BASE_URL);
560        // The Model API key never reaches the runtime: broker-proxied.
561        assert_eq!(route.auth, AuthPolicy::BrokerProxy);
562        assert_eq!(route.wire, WireProtocol::OpenAiResponses);
563        // Server-driven catalog (registry precedent): any safe-shaped id
564        // routes WITHOUT a live catalog in this process — Meta decides
565        // existence. This is what lets a daemon-hosted session switch to a
566        // model only the TUI's picker fetched.
567        let live_only = resolve_route("muse/muse-spark-9.9").expect("shape-valid id routes");
568        assert_eq!(live_only.model, "muse-spark-9.9");
569        assert_eq!(live_only.wire, WireProtocol::OpenAiResponses);
570        // Malformed ids fail closed: they never reach the request body.
571        for banned in ["MUSE-WONDER-1.0", "", "a b", "../x", "-x", "a/b"] {
572            assert!(
573                resolve_route(&format!("muse/{banned}")).is_none(),
574                "{banned} must not resolve"
575            );
576        }
577    }
578
579    #[test]
580    fn kimi_code_routing_is_broker_proxied_and_fail_closed() {
581        for descriptor in catalog::KIMI_CODE_TEXT_MODELS {
582            let route = resolve_route(&format!("kimi-code/{}", descriptor.id)).unwrap();
583            assert_eq!(route.provider, "kimi-code");
584            assert_eq!(route.model, descriptor.id);
585            assert_eq!(route.endpoint, "https://api.kimi.com/coding/v1");
586            // Broker-proxied: the short-lived OAuth bearer never reaches the
587            // runtime; the broker resolves + attaches it per request.
588            assert_eq!(route.auth, AuthPolicy::BrokerProxy);
589            assert_eq!(route.wire, WireProtocol::OpenAiChatCompletions);
590        }
591        // Unknown ids and the static Moonshot-platform ids fail closed on
592        // this route (they belong to the API-key `kimi` provider).
593        assert!(resolve_route("kimi-code/unknown-model").is_none());
594        assert!(resolve_route("kimi-code/kimi-k3").is_none());
595        assert!(resolve_route("kimi-code/kimi-k2.7-code").is_none());
596    }
597
598    #[test]
599    fn resolves_openai_codex_without_requiring_eager_credentials() {
600        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
601        let route = resolve_route("openai-codex/gpt-5.1-codex-mini").unwrap();
602        assert_eq!(route.provider, "openai-codex");
603        assert_eq!(route.wire, WireProtocol::CodexResponses);
604    }
605
606    /// Regression: models under the `google-gemini/*` prefix must route through
607    /// `try_route` (i.e. the OpenAI-runtime dispatcher must return `Some(_)` so
608    /// the caller invokes the Gemini stream rather than falling through to the
609    /// Anthropic path and reporting a misleading 401. Before this fix,
610    /// `WireProtocol::GoogleGeminiCodeAssist => None` made `try_route` return
611    /// `None`.
612    ///
613    /// We drive `try_route` through the `Remote` credential source pointed at a
614    /// closed loopback port so the broker request errors out fast without
615    /// touching the developer's real auth.json or the network. The outcome we
616    /// assert is only that the routing dispatcher returned `Some(_)` — the
617    /// specific broker error is irrelevant.
618    #[tokio::test]
619    async fn try_route_google_gemini_is_not_none_and_does_not_fall_through_to_anthropic() {
620        let client = reqwest::Client::builder()
621            .connect_timeout(std::time::Duration::from_secs(2))
622            .build()
623            .expect("client");
624        // 127.0.0.1:1 is a reserved port; on Linux connect(2) returns
625        // ECONNREFUSED immediately so the broker call fails fast.
626        let source = crate::auth::CredentialSource::Remote {
627            endpoint: "http://127.0.0.1:1".into(),
628            machine_token: "not-a-real-token".into(),
629        };
630        let cache = crate::auth::TokenCache::new();
631        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
632        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
633        let cancel = tokio_util::sync::CancellationToken::new();
634
635        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
636            "role": "user",
637            "content": "hi"
638        }))];
639
640        let trace = crate::runtime::trace::TraceContext::disabled();
641        let fut = try_route(
642            "google-gemini/gemini-2.5-pro",
643            &client,
644            &tools_schema,
645            &None,
646            &messages,
647            &tx,
648            None,
649            None,
650            0,
651            agent_core::reasoning::ReasoningLevel::Adaptive,
652            &cancel,
653            &source,
654            &cache,
655            0,
656            catalog::CodexRequestRole::Foreground,
657            None,
658            None,
659            &trace,
660            false,
661        );
662        let result = tokio::time::timeout(std::time::Duration::from_secs(10), fut)
663            .await
664            .expect("try_route completed within budget");
665
666        assert!(
667            result.is_some(),
668            "google-gemini must route through try_route (was None → fell through to Anthropic)"
669        );
670    }
671
672    /// Headless harness (no live credentials): rejected xAI reasoning
673    /// combinations must fail BEFORE any credential or network access on the
674    /// Responses path. The Remote credential source points at a closed
675    /// loopback port — if validation ran after broker access this would
676    /// surface a connection error instead of the capability message.
677    #[tokio::test]
678    async fn xai_off_is_rejected_pre_network_through_try_route() {
679        let client = reqwest::Client::builder()
680            .connect_timeout(std::time::Duration::from_secs(2))
681            .build()
682            .expect("client");
683        let source = crate::auth::CredentialSource::Remote {
684            endpoint: "http://127.0.0.1:1".into(),
685            machine_token: "not-a-real-token".into(),
686        };
687        let cache = crate::auth::TokenCache::new();
688        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
689        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
690        let cancel = tokio_util::sync::CancellationToken::new();
691        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
692            "role": "user",
693            "content": "hi"
694        }))];
695
696        for (level, needle) in [
697            (
698                agent_core::reasoning::ReasoningLevel::Off,
699                "cannot be disabled",
700            ),
701            (
702                agent_core::reasoning::ReasoningLevel::XHigh,
703                "not supported",
704            ),
705            (
706                agent_core::reasoning::ReasoningLevel::Ultra,
707                "not supported",
708            ),
709        ] {
710            let result = try_route(
711                "xai-auth/grok-4.5",
712                &client,
713                &tools_schema,
714                &None,
715                &messages,
716                &tx,
717                None,
718                None,
719                0,
720                level,
721                &cancel,
722                &source,
723                &cache,
724                0,
725                catalog::CodexRequestRole::Foreground,
726                None,
727                None,
728                &crate::runtime::trace::TraceContext::disabled(),
729                false,
730            )
731            .await
732            .expect("xai-auth must route through try_route");
733            let err = result.expect_err("rejected level must error");
734            assert!(
735                err.to_string().contains(needle),
736                "{level}: pre-network capability rejection expected, got: {err}"
737            );
738        }
739    }
740
741    // The routing manager is process-wide: tests that install one share a key.
742    #[test]
743    #[serial_test::serial(extension_routing)]
744    fn set_extension_manager_for_routing_overwrites_previous_manager() {
745        clear_extension_manager_for_routing();
746        let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
747            crate::extensions::hooks::HookBus::new(),
748        ))));
749        let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
750            crate::extensions::hooks::HookBus::new(),
751        ))));
752
753        set_extension_manager_for_routing(first.clone());
754        assert!(Arc::ptr_eq(
755            &extension_manager_for_routing().unwrap(),
756            &first
757        ));
758
759        set_extension_manager_for_routing(second.clone());
760        assert!(Arc::ptr_eq(
761            &extension_manager_for_routing().unwrap(),
762            &second
763        ));
764
765        clear_extension_manager_for_routing();
766        assert!(extension_manager_for_routing().is_none());
767    }
768}