1pub(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 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
77pub 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 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 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 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#[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
256pub(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#[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 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 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 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 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 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 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 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 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 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", ] {
534 assert!(
535 resolve_route(&format!("google-gemini/{banned}")).is_none(),
536 "{banned} must not resolve"
537 );
538 }
539 }
540
541 #[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 assert_eq!(route.auth, AuthPolicy::BrokerProxy);
560 assert_eq!(route.wire, WireProtocol::OpenAiResponses);
561 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 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 assert_eq!(route.auth, AuthPolicy::BrokerProxy);
587 assert_eq!(route.wire, WireProtocol::OpenAiChatCompletions);
588 }
589 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 #[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 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 #[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}