1pub mod catalog;
8pub(crate) mod extension_route;
9pub mod net;
10pub mod ping;
11pub mod reasoning;
12pub mod registry;
13pub mod stream;
14pub mod translate;
15pub mod types;
16pub mod wire;
17
18use std::sync::Arc;
19
20use crate::extensions::manager::ExtensionManager;
21use crate::extensions::providers::ProviderRegistry;
22
23pub use types::{
24 ChatMessage, ChatOptions, ChatRequest, FunctionCall, FunctionDefinition, OaiEvent,
25 ProviderConfig, StreamOptions, ToolCall, ToolChoice, ToolDefinition,
26};
27pub use wire::StreamDecoder;
28
29static EXTENSION_MANAGER: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<ExtensionManager>>>> =
30 std::sync::RwLock::new(None);
31
32pub fn set_extension_manager_for_routing(manager: Arc<tokio::sync::RwLock<ExtensionManager>>) {
33 *EXTENSION_MANAGER
34 .write()
35 .expect("extension manager routing lock poisoned") = Some(manager);
36}
37
38pub fn clear_extension_manager_for_routing() {
39 *EXTENSION_MANAGER
40 .write()
41 .expect("extension manager routing lock poisoned") = None;
42}
43
44pub fn extension_manager_for_routing() -> Option<Arc<tokio::sync::RwLock<ExtensionManager>>> {
45 EXTENSION_MANAGER
46 .read()
47 .expect("extension manager routing lock poisoned")
48 .clone()
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AuthPolicy {
53 OAuthAccessToken(crate::auth::OAuthProviderId),
54 BrokerProxy,
55}
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum WireProtocol {
58 AnthropicMessages,
59 OpenAiChatCompletions,
60 OpenAiResponses,
61 CodexResponses,
62 GoogleGeminiCodeAssist,
65}
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ResolvedRoute {
68 pub endpoint: String,
69 pub model: String,
70 pub provider: String,
71 pub auth: AuthPolicy,
72 pub wire: WireProtocol,
73}
74
75pub fn resolve_route(model: &str) -> Option<ResolvedRoute> {
77 if let Some((prefix, rest)) = model.split_once('/') {
78 if prefix == "anthropic" && rest.starts_with("claude-") {
79 return Some(ResolvedRoute {
80 endpoint: "https://api.anthropic.com".into(),
81 model: rest.into(),
82 provider: prefix.into(),
83 auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
84 wire: WireProtocol::AnthropicMessages,
85 });
86 }
87 if prefix == "xai-auth" && catalog::xai_model(rest).is_some() {
88 return Some(ResolvedRoute {
89 endpoint: "https://api.x.ai/v1".into(),
90 model: rest.into(),
91 provider: prefix.into(),
92 auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai),
93 wire: WireProtocol::OpenAiResponses,
94 });
95 }
96 if prefix == "github-copilot" {
97 let descriptor = catalog::github_copilot_runtime_model(rest)?;
98 return Some(ResolvedRoute {
99 endpoint: catalog::MODELS_BASE_URL.into(),
100 model: rest.into(),
101 provider: prefix.into(),
102 auth: AuthPolicy::BrokerProxy,
103 wire: descriptor,
104 });
105 }
106 if prefix == "google-gemini" {
107 catalog::google_gemini_model(rest)?;
111 return Some(ResolvedRoute {
112 endpoint: crate::auth::broker::GOOGLE_GEMINI_CODE_ASSIST_BASE_URL.into(),
113 model: rest.into(),
114 provider: prefix.into(),
115 auth: AuthPolicy::BrokerProxy,
116 wire: WireProtocol::GoogleGeminiCodeAssist,
117 });
118 }
119 if prefix == "openai-codex" {
120 let c = registry::resolve_codex_shorthand(model)?;
121 return Some(ResolvedRoute {
122 endpoint: c.base_url,
123 model: c.model,
124 provider: c.provider,
125 auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::OpenAiCodex),
126 wire: WireProtocol::CodexResponses,
127 });
128 }
129 if prefix == "local" {
130 let c = registry::resolve_provider_model(prefix, rest)?;
131 return Some(ResolvedRoute {
132 endpoint: c.base_url,
133 model: c.model,
134 provider: c.provider,
135 auth: AuthPolicy::BrokerProxy,
136 wire: WireProtocol::OpenAiChatCompletions,
137 });
138 }
139 if let Some(spec) = registry::providers().iter().find(|s| s.key == prefix) {
140 return Some(ResolvedRoute {
141 endpoint: spec.base_url.into(),
142 model: rest.into(),
143 provider: prefix.into(),
144 auth: AuthPolicy::BrokerProxy,
145 wire: WireProtocol::OpenAiChatCompletions,
146 });
147 }
148 return None;
149 }
150 Some(ResolvedRoute {
151 endpoint: "https://api.anthropic.com".into(),
152 model: model.into(),
153 provider: "anthropic".into(),
154 auth: AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Anthropic),
155 wire: WireProtocol::AnthropicMessages,
156 })
157}
158fn provider_config(r: &ResolvedRoute) -> ProviderConfig {
159 ProviderConfig {
160 base_url: r.endpoint.clone(),
161 model: r.model.clone(),
162 provider: r.provider.clone(),
163 }
164}
165
166#[allow(clippy::too_many_arguments)]
177pub async fn try_route(
178 model: &str,
179 client: &reqwest::Client,
180 tools_schema: &std::sync::Arc<Vec<serde_json::Value>>,
181 system_prompt: &Option<String>,
182 messages: &[crate::SharedMessage],
183 tx: &tokio::sync::mpsc::UnboundedSender<crate::runtime::types::StreamEvent>,
184 temperature: Option<f32>,
185 max_tokens: Option<u32>,
186 thinking_budget: u32,
187 reasoning_level: agent_core::reasoning::ReasoningLevel,
188 cancel: &tokio_util::sync::CancellationToken,
189 source: &crate::auth::CredentialSource,
190 cache: &crate::auth::TokenCache,
191 max_retries: u32,
192 codex_request_role: catalog::CodexRequestRole,
193 tool_session_id: Option<&crate::tools::activation::SessionId>,
194 session_tool_set: Option<&crate::tools::activation::SharedSessionToolSet>,
195 trace: &crate::runtime::trace::TraceContext,
196 suppress_stream_deltas: bool,
197) -> Option<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>> {
198 if let Some((plugin_id, provider_id, model_id)) = ProviderRegistry::parse_model_id(model) {
199 if let Some(manager) = extension_manager_for_routing() {
200 return Some(
203 extension_route::route_extension_provider(
204 manager,
205 plugin_id,
206 provider_id,
207 model_id,
208 model,
209 tools_schema,
210 system_prompt,
211 messages,
212 tx,
213 temperature,
214 max_tokens,
215 thinking_budget,
216 cancel,
217 tool_session_id,
218 session_tool_set,
219 trace,
220 )
221 .await,
222 );
223 }
224 return Some(Err(format!(
225 "Extension provider model '{}' is not available",
226 model
227 )
228 .into()));
229 }
230
231 let Some(route) = resolve_route(model) else {
232 return Some(Err(
233 format!("Unknown provider route for model '{model}'").into()
234 ));
235 };
236 let cfg = provider_config(&route);
237 let broker = crate::auth::broker_from_source(source, cache, client.clone());
238 let exact_wire_bytes = !source.is_remote();
242 match route.wire {
243 WireProtocol::OpenAiChatCompletions => Some(
244 stream::call_oai_stream_inner(
245 &cfg,
246 &broker,
247 tools_schema,
248 system_prompt,
249 messages,
250 tx,
251 temperature,
252 max_tokens,
253 thinking_budget,
254 reasoning_level,
255 cancel,
256 trace,
257 exact_wire_bytes,
258 suppress_stream_deltas,
259 )
260 .await,
261 ),
262 WireProtocol::OpenAiResponses => {
263 if suppress_stream_deltas {
264 return Some(Err(
265 "internal sync routing is unavailable for this streaming provider".into(),
266 ));
267 }
268 Some(
269 stream::call_xai_responses_stream_inner(
270 &cfg,
271 &broker,
272 tools_schema,
273 system_prompt,
274 messages,
275 tx,
276 max_tokens,
277 reasoning_level,
278 cancel,
279 trace,
280 exact_wire_bytes,
281 )
282 .await,
283 )
284 }
285 WireProtocol::CodexResponses => {
286 if suppress_stream_deltas {
287 return Some(Err(
288 "internal sync routing is unavailable for this streaming provider".into(),
289 ));
290 }
291 Some(
292 stream::call_codex_stream_inner(
293 &cfg,
294 client,
295 &broker,
296 tools_schema,
297 system_prompt,
298 messages,
299 tx,
300 temperature,
301 max_tokens,
302 reasoning_level,
303 codex_request_role,
304 cancel,
305 stream::codex_retry_budget(max_retries),
309 trace,
310 )
311 .await,
312 )
313 }
314 WireProtocol::AnthropicMessages => None,
315 WireProtocol::GoogleGeminiCodeAssist => {
316 if suppress_stream_deltas {
317 return Some(Err(
318 "internal sync routing is unavailable for this streaming provider".into(),
319 ));
320 }
321 Some(
322 crate::runtime::google_gemini::runtime::call_google_gemini_stream_inner(
323 &cfg,
324 &broker,
325 tools_schema,
326 system_prompt,
327 messages,
328 tx,
329 cancel,
330 trace,
331 exact_wire_bytes,
332 )
333 .await,
334 )
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn anthropic_identity_is_explicit_while_legacy_bare_ids_still_route() {
345 for id in ["claude-sonnet-4-6", "claude-opus-4-7"] {
346 let qualified = resolve_route(&format!("anthropic/{id}"))
347 .expect("qualified Anthropic model must route");
348 assert_eq!(qualified.provider, "anthropic");
349 assert_eq!(qualified.model, id);
350 assert_eq!(qualified.wire, WireProtocol::AnthropicMessages);
351
352 let legacy = resolve_route(id).expect("legacy bare Claude model must route");
353 assert_eq!(legacy.provider, "anthropic");
354 assert_eq!(legacy.model, id);
355 }
356 assert_eq!(
357 resolve_route("github-copilot/claude-sonnet-4.6")
358 .unwrap()
359 .provider,
360 "github-copilot"
361 );
362 }
363
364 #[test]
365 fn brokered_provider_routing_is_typed_and_fail_closed() {
366 for descriptor in catalog::XAI_TEXT_MODELS {
367 let runtime_id = format!("xai-auth/{}", descriptor.id);
368 let xai = resolve_route(&runtime_id).unwrap();
369 assert_eq!(xai.model, descriptor.id);
370 assert_eq!(xai.provider, "xai-auth");
371 assert_eq!(xai.endpoint, "https://api.x.ai/v1");
372 assert_eq!(
373 xai.auth,
374 AuthPolicy::OAuthAccessToken(crate::auth::OAuthProviderId::Xai)
375 );
376 assert_eq!(xai.wire, WireProtocol::OpenAiResponses);
377 }
378 let static_route = resolve_route("groq/llama-3.3-70b-versatile").unwrap();
379 assert_eq!(static_route.auth, AuthPolicy::BrokerProxy);
380 assert_eq!(static_route.wire, WireProtocol::OpenAiChatCompletions);
381
382 let copilot_chat = resolve_route("github-copilot/claude-sonnet-4.6").unwrap();
383 assert_eq!(copilot_chat.auth, AuthPolicy::BrokerProxy);
384 assert_eq!(copilot_chat.wire, WireProtocol::OpenAiChatCompletions);
385 let copilot_responses = resolve_route("github-copilot/gpt-5.3-codex").unwrap();
386 assert_eq!(copilot_responses.auth, AuthPolicy::BrokerProxy);
387 assert_eq!(copilot_responses.wire, WireProtocol::OpenAiResponses);
388 assert!(resolve_route("github-copilot/unverified-model").is_none());
389 assert!(resolve_route("unknown-provider/model").is_none());
390 assert!(resolve_route("xai-auth/not-a-real-model").is_none());
391 assert!(resolve_route("xai-auth/grok-build-0.1").is_none());
392 assert!(resolve_route("xai-auth/grok-build-latest").is_none());
393 assert!(resolve_route("xai-auth/grok-imagine-image").is_none());
394
395 for descriptor in catalog::GOOGLE_GEMINI_TEXT_MODELS {
397 let route = resolve_route(&format!("google-gemini/{}", descriptor.id)).unwrap();
398 assert_eq!(route.provider, "google-gemini");
399 assert_eq!(route.model, descriptor.id);
400 assert_eq!(route.endpoint, "https://cloudcode-pa.googleapis.com");
401 assert_eq!(route.auth, AuthPolicy::BrokerProxy);
402 assert_eq!(route.wire, WireProtocol::GoogleGeminiCodeAssist);
403 }
404 for banned in [
406 "auto-gemini-2.5",
407 "auto-gemini-3",
408 "text-embedding-004",
409 "gemini-2.5-image",
410 "gemini-2.5", ] {
412 assert!(
413 resolve_route(&format!("google-gemini/{banned}")).is_none(),
414 "{banned} must not resolve"
415 );
416 }
417 }
418
419 #[test]
423 fn does_not_resolve_public_api_alias_gemini_pro_latest() {
424 assert!(
425 resolve_route("google-gemini/gemini-pro-latest").is_none(),
426 "gemini-pro-latest is not a Code Assist wire ID (404 upstream)"
427 );
428 }
429
430 #[test]
431 fn resolves_openai_codex_without_requiring_eager_credentials() {
432 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
433 let route = resolve_route("openai-codex/gpt-5.1-codex-mini").unwrap();
434 assert_eq!(route.provider, "openai-codex");
435 assert_eq!(route.wire, WireProtocol::CodexResponses);
436 }
437
438 #[tokio::test]
451 async fn try_route_google_gemini_is_not_none_and_does_not_fall_through_to_anthropic() {
452 let client = reqwest::Client::builder()
453 .connect_timeout(std::time::Duration::from_secs(2))
454 .build()
455 .expect("client");
456 let source = crate::auth::CredentialSource::Remote {
459 endpoint: "http://127.0.0.1:1".into(),
460 machine_token: "not-a-real-token".into(),
461 };
462 let cache = crate::auth::TokenCache::new();
463 let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
464 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
465 let cancel = tokio_util::sync::CancellationToken::new();
466
467 let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
468 "role": "user",
469 "content": "hi"
470 }))];
471
472 let trace = crate::runtime::trace::TraceContext::disabled();
473 let fut = try_route(
474 "google-gemini/gemini-2.5-pro",
475 &client,
476 &tools_schema,
477 &None,
478 &messages,
479 &tx,
480 None,
481 None,
482 0,
483 agent_core::reasoning::ReasoningLevel::Adaptive,
484 &cancel,
485 &source,
486 &cache,
487 0,
488 catalog::CodexRequestRole::Foreground,
489 None,
490 None,
491 &trace,
492 false,
493 );
494 let result = tokio::time::timeout(std::time::Duration::from_secs(10), fut)
495 .await
496 .expect("try_route completed within budget");
497
498 assert!(
499 result.is_some(),
500 "google-gemini must route through try_route (was None → fell through to Anthropic)"
501 );
502 }
503
504 #[tokio::test]
510 async fn xai_off_is_rejected_pre_network_through_try_route() {
511 let client = reqwest::Client::builder()
512 .connect_timeout(std::time::Duration::from_secs(2))
513 .build()
514 .expect("client");
515 let source = crate::auth::CredentialSource::Remote {
516 endpoint: "http://127.0.0.1:1".into(),
517 machine_token: "not-a-real-token".into(),
518 };
519 let cache = crate::auth::TokenCache::new();
520 let tools_schema: std::sync::Arc<Vec<serde_json::Value>> = std::sync::Arc::new(Vec::new());
521 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
522 let cancel = tokio_util::sync::CancellationToken::new();
523 let messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(serde_json::json!({
524 "role": "user",
525 "content": "hi"
526 }))];
527
528 for (level, needle) in [
529 (
530 agent_core::reasoning::ReasoningLevel::Off,
531 "cannot be disabled",
532 ),
533 (
534 agent_core::reasoning::ReasoningLevel::XHigh,
535 "not supported",
536 ),
537 (
538 agent_core::reasoning::ReasoningLevel::Ultra,
539 "not supported",
540 ),
541 ] {
542 let result = try_route(
543 "xai-auth/grok-4.5",
544 &client,
545 &tools_schema,
546 &None,
547 &messages,
548 &tx,
549 None,
550 None,
551 0,
552 level,
553 &cancel,
554 &source,
555 &cache,
556 0,
557 catalog::CodexRequestRole::Foreground,
558 None,
559 None,
560 &crate::runtime::trace::TraceContext::disabled(),
561 false,
562 )
563 .await
564 .expect("xai-auth must route through try_route");
565 let err = result.expect_err("rejected level must error");
566 assert!(
567 err.to_string().contains(needle),
568 "{level}: pre-network capability rejection expected, got: {err}"
569 );
570 }
571 }
572
573 #[test]
574 fn set_extension_manager_for_routing_overwrites_previous_manager() {
575 clear_extension_manager_for_routing();
576 let first = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
577 crate::extensions::hooks::HookBus::new(),
578 ))));
579 let second = Arc::new(tokio::sync::RwLock::new(ExtensionManager::new(Arc::new(
580 crate::extensions::hooks::HookBus::new(),
581 ))));
582
583 set_extension_manager_for_routing(first.clone());
584 assert!(Arc::ptr_eq(
585 &extension_manager_for_routing().unwrap(),
586 &first
587 ));
588
589 set_extension_manager_for_routing(second.clone());
590 assert!(Arc::ptr_eq(
591 &extension_manager_for_routing().unwrap(),
592 &second
593 ));
594
595 clear_extension_manager_for_routing();
596 assert!(extension_manager_for_routing().is_none());
597 }
598}