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 retaining the host's immutable memory binding. The public
272/// compatibility facade above has no runtime capability and cannot mint one.
273#[allow(clippy::too_many_arguments)]
274pub(crate) async fn try_route_with_memory_backend(
275    model: &str,
276    client: &reqwest::Client,
277    tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
278    system_prompt: &Option<String>,
279    messages: &[crate::SharedMessage],
280    tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
281    temperature: Option<f32>,
282    max_tokens: Option<u32>,
283    thinking_budget: u32,
284    reasoning_level: agent_core::reasoning::ReasoningLevel,
285    cancel: &tokio_util::sync::CancellationToken,
286    source: &crate::auth::CredentialSource,
287    cache: &crate::auth::TokenCache,
288    max_retries: u32,
289    codex_request_role: catalog::CodexRequestRole,
290    tool_session_id: Option<&crate::tools::activation::SessionId>,
291    session_tool_set: Option<&crate::tools::activation::SharedSessionToolSet>,
292    memory_backend: Option<&crate::memory_backend::MemoryBinding>,
293    trace: &crate::runtime::trace::TraceContext,
294    suppress_stream_deltas: bool,
295) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
296    // Muse capability evidence (exact effort set, modalities) lives in the
297    // process-local cache; load it here via the turn's broker when this
298    // process has none for the model, BEFORE body validation consults it.
299    if model.starts_with("muse/") {
300        let broker = crate::auth::broker_from_source(source, cache, client.clone());
301        catalog::ensure_muse_catalog_for(model, &broker).await;
302    }
303    if let Err(error) = super::attachments::validate_history_for_request(model, messages) {
304        return Some(Err(error.into()));
305    }
306    // Host `_synaps_*` keys never reach any of these transports (some
307    // forward the history verbatim). Only the native Anthropic route, which
308    // returns `None` below without reading `messages`, skips the copy.
309    let wire = routed_wire_messages(model, messages);
310    let messages = wire.as_deref().unwrap_or(messages);
311    if let Some((plugin_id, provider_id, model_id)) = ProviderRegistry::parse_model_id(model) {
312        if let Some(manager) = extension_manager_for_routing() {
313            // Extension-hosted provider: routing gates, audits, and the
314            // Task 10C transport trace live in `extension_route`.
315            return Some(
316                extension_route::route_extension_provider(
317                    manager,
318                    plugin_id,
319                    provider_id,
320                    model_id,
321                    model,
322                    tools_schema,
323                    system_prompt,
324                    messages,
325                    tx,
326                    temperature,
327                    max_tokens,
328                    thinking_budget,
329                    cancel,
330                    tool_session_id,
331                    session_tool_set,
332                    memory_backend,
333                    trace,
334                )
335                .await,
336            );
337        }
338        return Some(Err(format!(
339            "Extension provider model '{}' is not available",
340            model
341        )
342        .into()));
343    }
344
345    let Some(route) = resolve_route(model) else {
346        return Some(Err(
347            format!("Unknown provider route for model '{model}'").into()
348        ));
349    };
350    let cfg = provider_config(&route);
351    let broker = crate::auth::broker_from_source(source, cache, client.clone());
352    // Trace honesty (Task 10A): only a local (in-process) broker sends the
353    // exact bytes this process serialized (`ProxyRequest::body_bytes`); a
354    // remote broker daemon re-serializes upstream bodies out of process.
355    let exact_wire_bytes = !source.is_remote();
356    match route.wire {
357        WireProtocol::OpenAiChatCompletions => Some(
358            stream::call_oai_stream_inner(
359                &cfg,
360                &broker,
361                tools_schema,
362                system_prompt,
363                messages,
364                tx,
365                temperature,
366                max_tokens,
367                thinking_budget,
368                reasoning_level,
369                cancel,
370                trace,
371                exact_wire_bytes,
372                suppress_stream_deltas,
373                max_retries,
374            )
375            .await,
376        ),
377        WireProtocol::OpenAiResponses => {
378            if suppress_stream_deltas {
379                return Some(Err(
380                    "internal sync routing is unavailable for this streaming provider".into(),
381                ));
382            }
383            // Same wire, per-provider body/identity: Meta Muse mirrors the
384            // official Muse Code request shape; everything else is xAI.
385            Some(
386                stream::call_responses_route_inner(
387                    &route.provider,
388                    &cfg,
389                    &broker,
390                    tools_schema,
391                    system_prompt,
392                    messages,
393                    tx,
394                    max_tokens,
395                    reasoning_level,
396                    cancel,
397                    // In-stream capacity errors (HTTP 200 + lone `error`
398                    // event) are transient; re-send identical bytes with
399                    // the configured retry budget.
400                    max_retries,
401                    trace,
402                    exact_wire_bytes,
403                )
404                .await,
405            )
406        }
407        WireProtocol::CodexResponses => {
408            if suppress_stream_deltas {
409                return Some(Err(
410                    "internal sync routing is unavailable for this streaming provider".into(),
411                ));
412            }
413            Some(
414                stream::call_codex_stream_inner(
415                    &cfg,
416                    client,
417                    &broker,
418                    tools_schema,
419                    system_prompt,
420                    messages,
421                    tx,
422                    temperature,
423                    max_tokens,
424                    reasoning_level,
425                    codex_request_role,
426                    cancel,
427                    // Codex transport rides out chatgpt.com edge bursts with the
428                    // same persistent posture as Anthropic OAuth overloads (10
429                    // retries), not the generic three-attempt budget.
430                    stream::codex_retry_budget(max_retries),
431                    trace,
432                )
433                .await,
434            )
435        }
436        WireProtocol::AnthropicMessages => None,
437        WireProtocol::GoogleGeminiCodeAssist => {
438            if suppress_stream_deltas {
439                return Some(Err(
440                    "internal sync routing is unavailable for this streaming provider".into(),
441                ));
442            }
443            Some(
444                crate::runtime::google_gemini::runtime::call_google_gemini_stream_inner(
445                    &cfg,
446                    &broker,
447                    tools_schema,
448                    system_prompt,
449                    messages,
450                    tx,
451                    cancel,
452                    trace,
453                    exact_wire_bytes,
454                )
455                .await,
456            )
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn anthropic_identity_is_explicit_while_legacy_bare_ids_still_route() {
467        for id in ["claude-sonnet-4-6", "claude-opus-4-7"] {
468            let qualified = resolve_route(&format!("anthropic/{id}"))
469                .expect("qualified Anthropic model must route");
470            assert_eq!(qualified.provider, "anthropic");
471            assert_eq!(qualified.model, id);
472            assert_eq!(qualified.wire, WireProtocol::AnthropicMessages);
473
474            let legacy = resolve_route(id).expect("legacy bare Claude model must route");
475            assert_eq!(legacy.provider, "anthropic");
476            assert_eq!(legacy.model, id);
477        }
478        assert_eq!(
479            resolve_route("github-copilot/claude-sonnet-4.6")
480                .unwrap()
481                .provider,
482            "github-copilot"
483        );
484    }
485
486    #[test]
487    fn brokered_provider_routing_is_typed_and_fail_closed() {
488        for descriptor in catalog::XAI_TEXT_MODELS {
489            let runtime_id = format!("xai-auth/{}", descriptor.id);
490            let xai = resolve_route(&runtime_id).unwrap();
491            assert_eq!(xai.model, descriptor.id);
492            assert_eq!(xai.provider, "xai-auth");
493            assert_eq!(xai.endpoint, "https://api.x.ai/v1");
494            assert_eq!(
495                xai.auth,
496                AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai)
497            );
498            assert_eq!(xai.wire, WireProtocol::OpenAiResponses);
499        }
500        let static_route = resolve_route("groq/llama-3.3-70b-versatile").unwrap();
501        assert_eq!(static_route.auth, AuthPolicy::BrokerProxy);
502        assert_eq!(static_route.wire, WireProtocol::OpenAiChatCompletions);
503
504        let copilot_chat = resolve_route("github-copilot/claude-sonnet-4.6").unwrap();
505        assert_eq!(copilot_chat.auth, AuthPolicy::BrokerProxy);
506        assert_eq!(copilot_chat.wire, WireProtocol::OpenAiChatCompletions);
507        let copilot_responses = resolve_route("github-copilot/gpt-5.3-codex").unwrap();
508        assert_eq!(copilot_responses.auth, AuthPolicy::BrokerProxy);
509        assert_eq!(copilot_responses.wire, WireProtocol::OpenAiResponses);
510        assert!(resolve_route("github-copilot/unverified-model").is_none());
511        assert!(resolve_route("unknown-provider/model").is_none());
512        assert!(resolve_route("xai-auth/not-a-real-model").is_none());
513        assert!(resolve_route("xai-auth/grok-build-0.1").is_none());
514        assert!(resolve_route("xai-auth/grok-build-latest").is_none());
515        assert!(resolve_route("xai-auth/grok-imagine-image").is_none());
516
517        // Every verified concrete text/tool wire ID in the catalog must resolve.
518        for descriptor in catalog::GOOGLE_GEMINI_TEXT_MODELS {
519            let route = resolve_route(&format!("google-gemini/{}", descriptor.id)).unwrap();
520            assert_eq!(route.provider, "google-gemini");
521            assert_eq!(route.model, descriptor.id);
522            assert_eq!(route.endpoint, "https://cloudcode-pa.googleapis.com");
523            assert_eq!(route.auth, AuthPolicy::BrokerProxy);
524            assert_eq!(route.wire, WireProtocol::GoogleGeminiCodeAssist);
525        }
526        // Aliases, unsupported media/embedding models, and family prefixes fail closed.
527        for banned in [
528            "auto-gemini-2.5",
529            "auto-gemini-3",
530            "text-embedding-004",
531            "gemini-2.5-image",
532            "gemini-2.5", // family prefix is not a wire id
533        ] {
534            assert!(
535                resolve_route(&format!("google-gemini/{banned}")).is_none(),
536                "{banned} must not resolve"
537            );
538        }
539    }
540
541    /// Regression: `gemini-pro-latest` must NOT resolve — it is a public
542    /// Gemini API alias that Code Assist rejects with 404 NOT_FOUND. Routing
543    /// fails closed instead of emitting a doomed upstream request.
544    #[test]
545    fn does_not_resolve_public_api_alias_gemini_pro_latest() {
546        assert!(
547            resolve_route("google-gemini/gemini-pro-latest").is_none(),
548            "gemini-pro-latest is not a Code Assist wire ID (404 upstream)"
549        );
550    }
551
552    #[test]
553    fn muse_routing_is_broker_proxied_responses_and_fail_closed() {
554        let route = resolve_route("muse/muse-wonder-1.0").expect("documented seed routes");
555        assert_eq!(route.provider, "muse");
556        assert_eq!(route.model, "muse-wonder-1.0");
557        assert_eq!(route.endpoint, crate::auth::muse::API_BASE_URL);
558        // The Model API key never reaches the runtime: broker-proxied.
559        assert_eq!(route.auth, AuthPolicy::BrokerProxy);
560        assert_eq!(route.wire, WireProtocol::OpenAiResponses);
561        // Server-driven catalog (registry precedent): any safe-shaped id
562        // routes WITHOUT a live catalog in this process — Meta decides
563        // existence. This is what lets a daemon-hosted session switch to a
564        // model only the TUI's picker fetched.
565        let live_only = resolve_route("muse/muse-spark-9.9").expect("shape-valid id routes");
566        assert_eq!(live_only.model, "muse-spark-9.9");
567        assert_eq!(live_only.wire, WireProtocol::OpenAiResponses);
568        // Malformed ids fail closed: they never reach the request body.
569        for banned in ["MUSE-WONDER-1.0", "", "a b", "../x", "-x", "a/b"] {
570            assert!(
571                resolve_route(&format!("muse/{banned}")).is_none(),
572                "{banned} must not resolve"
573            );
574        }
575    }
576
577    #[test]
578    fn kimi_code_routing_is_broker_proxied_and_fail_closed() {
579        for descriptor in catalog::KIMI_CODE_TEXT_MODELS {
580            let route = resolve_route(&format!("kimi-code/{}", descriptor.id)).unwrap();
581            assert_eq!(route.provider, "kimi-code");
582            assert_eq!(route.model, descriptor.id);
583            assert_eq!(route.endpoint, "https://api.kimi.com/coding/v1");
584            // Broker-proxied: the short-lived OAuth bearer never reaches the
585            // runtime; the broker resolves + attaches it per request.
586            assert_eq!(route.auth, AuthPolicy::BrokerProxy);
587            assert_eq!(route.wire, WireProtocol::OpenAiChatCompletions);
588        }
589        // Unknown ids and the static Moonshot-platform ids fail closed on
590        // this route (they belong to the API-key `kimi` provider).
591        assert!(resolve_route("kimi-code/unknown-model").is_none());
592        assert!(resolve_route("kimi-code/kimi-k3").is_none());
593        assert!(resolve_route("kimi-code/kimi-k2.7-code").is_none());
594    }
595
596    #[test]
597    fn resolves_openai_codex_without_requiring_eager_credentials() {
598        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
599        let route = resolve_route("openai-codex/gpt-5.1-codex-mini").unwrap();
600        assert_eq!(route.provider, "openai-codex");
601        assert_eq!(route.wire, WireProtocol::CodexResponses);
602    }
603
604    /// Regression: models under the `google-gemini/*` prefix must route through
605    /// `try_route` (i.e. the OpenAI-runtime dispatcher must return `Some(_)` so
606    /// the caller invokes the Gemini stream rather than falling through to the
607    /// Anthropic path and reporting a misleading 401. Before this fix,
608    /// `WireProtocol::GoogleGeminiCodeAssist => None` made `try_route` return
609    /// `None`.
610    ///
611    /// We drive `try_route` through the `Remote` credential source pointed at a
612    /// closed loopback port so the broker request errors out fast without
613    /// touching the developer's real auth.json or the network. The outcome we
614    /// assert is only that the routing dispatcher returned `Some(_)` — the
615    /// specific broker error is irrelevant.
616    #[tokio::test]
617    async fn try_route_google_gemini_is_not_none_and_does_not_fall_through_to_anthropic() {
618        let client = reqwest::Client::builder()
619            .connect_timeout(std::time::Duration::from_secs(2))
620            .build()
621            .expect("client");
622        // 127.0.0.1:1 is a reserved port; on Linux connect(2) returns
623        // ECONNREFUSED immediately so the broker call fails fast.
624        let source = crate::auth::CredentialSource::Remote {
625            endpoint: "http://127.0.0.1:1".into(),
626            machine_token: "not-a-real-token".into(),
627        };
628        let cache = crate::auth::TokenCache::new();
629        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
630        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
631        let cancel = tokio_util::sync::CancellationToken::new();
632
633        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
634            "role": "user",
635            "content": "hi"
636        }))];
637
638        let trace = crate::runtime::trace::TraceContext::disabled();
639        let fut = try_route(
640            "google-gemini/gemini-2.5-pro",
641            &client,
642            &tools_schema,
643            &None,
644            &messages,
645            &tx,
646            None,
647            None,
648            0,
649            agent_core::reasoning::ReasoningLevel::Adaptive,
650            &cancel,
651            &source,
652            &cache,
653            0,
654            catalog::CodexRequestRole::Foreground,
655            None,
656            None,
657            &trace,
658            false,
659        );
660        let result = tokio::time::timeout(std::time::Duration::from_secs(10), fut)
661            .await
662            .expect("try_route completed within budget");
663
664        assert!(
665            result.is_some(),
666            "google-gemini must route through try_route (was None → fell through to Anthropic)"
667        );
668    }
669
670    /// Headless harness (no live credentials): rejected xAI reasoning
671    /// combinations must fail BEFORE any credential or network access on the
672    /// Responses path. The Remote credential source points at a closed
673    /// loopback port — if validation ran after broker access this would
674    /// surface a connection error instead of the capability message.
675    #[tokio::test]
676    async fn xai_off_is_rejected_pre_network_through_try_route() {
677        let client = reqwest::Client::builder()
678            .connect_timeout(std::time::Duration::from_secs(2))
679            .build()
680            .expect("client");
681        let source = crate::auth::CredentialSource::Remote {
682            endpoint: "http://127.0.0.1:1".into(),
683            machine_token: "not-a-real-token".into(),
684        };
685        let cache = crate::auth::TokenCache::new();
686        let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
687        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
688        let cancel = tokio_util::sync::CancellationToken::new();
689        let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
690            "role": "user",
691            "content": "hi"
692        }))];
693
694        for (level, needle) in [
695            (
696                agent_core::reasoning::ReasoningLevel::Off,
697                "cannot be disabled",
698            ),
699            (
700                agent_core::reasoning::ReasoningLevel::XHigh,
701                "not supported",
702            ),
703            (
704                agent_core::reasoning::ReasoningLevel::Ultra,
705                "not supported",
706            ),
707        ] {
708            let result = try_route(
709                "xai-auth/grok-4.5",
710                &client,
711                &tools_schema,
712                &None,
713                &messages,
714                &tx,
715                None,
716                None,
717                0,
718                level,
719                &cancel,
720                &source,
721                &cache,
722                0,
723                catalog::CodexRequestRole::Foreground,
724                None,
725                None,
726                &crate::runtime::trace::TraceContext::disabled(),
727                false,
728            )
729            .await
730            .expect("xai-auth must route through try_route");
731            let err = result.expect_err("rejected level must error");
732            assert!(
733                err.to_string().contains(needle),
734                "{level}: pre-network capability rejection expected, got: {err}"
735            );
736        }
737    }
738
739    #[test]
740    fn set_extension_manager_for_routing_overwrites_previous_manager() {
741        clear_extension_manager_for_routing();
742        let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
743            crate::extensions::hooks::HookBus::new(),
744        ))));
745        let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
746            crate::extensions::hooks::HookBus::new(),
747        ))));
748
749        set_extension_manager_for_routing(first.clone());
750        assert!(Arc::ptr_eq(
751            &extension_manager_for_routing().unwrap(),
752            &first
753        ));
754
755        set_extension_manager_for_routing(second.clone());
756        assert!(Arc::ptr_eq(
757            &extension_manager_for_routing().unwrap(),
758            &second
759        ));
760
761        clear_extension_manager_for_routing();
762        assert!(extension_manager_for_routing().is_none());
763    }
764}