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