返回 CodeWhale
chat_completions.rs
根目录 / crates / app-server / src / chat_completions.rs
1 //! Provider-neutral `/v1/chat/completions` pass-through endpoint.
2 //!
3 //! This module resolves a model inside the configured provider's authority and
4 //! forwards an OpenAI-compatible request body upstream. Model text is metadata:
5 //! it never selects a provider configuration or credential slot. It does
6 //! **not** import or call any DeepSeek-named client APIs — routing stays in
7 //! neutral config/provider types.
8 //!
9 //! Only providers whose [`WireFormat`] is [`WireFormat::ChatCompletions`] are
10 //! served. Streaming requests are explicitly rejected for now.
11
12 use std::collections::BTreeMap;
13
14 use axum::Json;
15 use axum::extract::State;
16 use axum::http::{HeaderName, StatusCode};
17 use axum::response::IntoResponse;
18 use codewhale_agent::ModelRegistry;
19 use codewhale_config::{
20 ConfigApiKeyValueKind, ConfigToml, ProviderKind, apply_openrouter_vendor,
21 auth_mode_disables_api_key, classify_config_api_key_value, is_upstream_auth_header,
22 provider::WireFormat,
23 provider_base_url_is_official, provider_preserves_custom_base_url_model,
24 route::{LogicalModelRef, RouteError, RouteRequest, RouteResolver},
25 validate_openrouter_vendor,
26 };
27 use serde_json::Value;
28
29 use super::AppState;
30
31 // ── Resolved endpoint ──────────────────────────────────────────────────
32
33 /// Everything needed to forward a single chat-completions request upstream.
34 #[derive(Debug, Clone)]
35 struct ResolvedModelEndpoint {
36 provider: ProviderKind,
37 base_url: String,
38 model: String,
39 api_key: Option<String>,
40 auth_disabled: bool,
41 http_headers: BTreeMap<String, String>,
42 path_suffix: Option<String>,
43 insecure_skip_tls_verify: bool,
44 wire_format: WireFormat,
45 }
46
47 // ── Resolution ─────────────────────────────────────────────────────────
48
49 /// Resolve a provider endpoint from the app configuration + an optional
50 /// `model` field pulled out of the incoming request body.
51 fn resolve_endpoint(
52 config: &ConfigToml,
53 registry: &ModelRegistry,
54 request_model: Option<&str>,
55 ) -> Result<ResolvedModelEndpoint, RouteError> {
56 // The configured provider is route authority. A request's model field can
57 // select only within that provider; it may never switch endpoints or
58 // credential slots by resembling another provider's catalog row.
59 let provider_kind = config.provider;
60 let provider_cfg = config.providers.for_provider(provider_kind);
61 let provider_meta = provider_kind.provider();
62
63 // Base URL: configured → default
64 let base_url = provider_base_url(config, provider_kind);
65 let endpoint_owns_models = endpoint_preserves_raw_model_ids(provider_kind, &base_url);
66
67 // ModelRegistry canonicalizes provider-owned aliases and detects clearly
68 // foreign rows. RouteResolver remains authoritative for the provider-scoped
69 // wire model and custom-endpoint passthrough contract.
70 let raw_selected_model = request_model
71 .filter(|m| !m.trim().is_empty())
72 .map(str::to_string)
73 .or_else(|| provider_cfg.model.clone())
74 .or_else(|| {
75 (provider_kind == ProviderKind::Deepseek)
76 .then(|| config.default_text_model.clone())
77 .flatten()
78 })
79 .unwrap_or_else(|| provider_meta.default_model().to_string());
80 let selected_model = if endpoint_owns_models {
81 raw_selected_model
82 } else {
83 match registry.resolve(Some(&raw_selected_model), Some(provider_kind)) {
84 Ok(resolved)
85 if !resolved.used_fallback && resolved.resolved.provider == provider_kind =>
86 {
87 resolved.resolved.id
88 }
89 Err(_) if registry.is_known_for_other_provider(&raw_selected_model, provider_kind) => {
90 return Err(RouteError::ForeignModelForDirectProvider {
91 provider: provider_kind.as_str().into(),
92 model: raw_selected_model,
93 });
94 }
95 // Registry metadata is advisory. An unknown future id stays in the
96 // selected provider's scope, where RouteResolver either accepts the
97 // provider's pass-through contract or rejects the model. It never
98 // borrows another provider's default or credentials.
99 Ok(_) | Err(_) => raw_selected_model,
100 }
101 };
102 let route = RouteResolver::new().resolve(&RouteRequest {
103 explicit_provider: Some(provider_kind),
104 model_selector: Some(LogicalModelRef::from(selected_model.as_str())),
105 saved_provider_model: None,
106 base_url_override: Some(base_url.clone()),
107 limit_overrides: Vec::new(),
108 })?;
109 let model = route.wire_model_id().as_str().to_string();
110
111 let auth_mode = provider_cfg.auth_mode.as_deref().or_else(|| {
112 (provider_kind == config.provider)
113 .then_some(config.auth_mode.as_deref())
114 .flatten()
115 });
116 let auth_disabled = auth_mode_disables_api_key(auth_mode);
117
118 let configured_api_key = provider_cfg.api_key.as_deref().or_else(|| {
119 (provider_kind == ProviderKind::Deepseek)
120 .then_some(config.api_key.as_deref())
121 .flatten()
122 });
123
124 // Provider auth comes only from the resolved endpoint configuration. The
125 // HTTP request's Authorization header authenticates the caller to the local
126 // app-server and is never a provider credential.
127 let api_key = resolve_upstream_api_key(
128 configured_api_key,
129 auth_disabled,
130 provider_base_url_is_official(provider_kind, &base_url),
131 || {
132 provider_meta
133 .env_vars()
134 .iter()
135 .find_map(|var| std::env::var(var).ok())
136 },
137 );
138
139 let mut http_headers = if provider_kind == config.provider {
140 config.http_headers.clone()
141 } else {
142 BTreeMap::new()
143 };
144 http_headers.extend(provider_cfg.http_headers.clone());
145 if auth_disabled {
146 http_headers.retain(|name, _| !is_upstream_auth_header(name));
147 }
148
149 let path_suffix = provider_cfg.path_suffix.clone();
150
151 let insecure_skip_tls_verify = provider_cfg.insecure_skip_tls_verify.unwrap_or(false);
152
153 let wire_format = route.protocol();
154
155 Ok(ResolvedModelEndpoint {
156 provider: provider_kind,
157 base_url,
158 model,
159 api_key,
160 auth_disabled,
161 http_headers,
162 path_suffix,
163 insecure_skip_tls_verify,
164 wire_format,
165 })
166 }
167
168 fn resolve_upstream_api_key(
169 configured: Option<&str>,
170 auth_disabled: bool,
171 allow_ambient: bool,
172 ambient_provider_env: impl FnOnce() -> Option<String>,
173 ) -> Option<String> {
174 if auth_disabled {
175 None
176 } else if let Some(configured) = configured
177 .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
178 {
179 Some(configured.to_string())
180 } else if allow_ambient {
181 ambient_provider_env()
182 } else {
183 None
184 }
185 }
186
187 fn provider_base_url(config: &ConfigToml, provider: ProviderKind) -> String {
188 let metadata = provider.provider();
189 config
190 .providers
191 .for_provider(provider)
192 .base_url
193 .clone()
194 .or_else(|| {
195 (provider == ProviderKind::Deepseek)
196 .then(|| config.base_url.clone())
197 .flatten()
198 })
199 .unwrap_or_else(|| metadata.default_base_url().to_string())
200 }
201
202 fn endpoint_preserves_raw_model_ids(provider: ProviderKind, base_url: &str) -> bool {
203 matches!(
204 provider,
205 ProviderKind::Custom
206 | ProviderKind::Ollama
207 | ProviderKind::OllamaCloud
208 | ProviderKind::Vllm
209 | ProviderKind::Sglang
210 | ProviderKind::OpencodeZen
211 ) || provider_preserves_custom_base_url_model(provider, base_url)
212 }
213
214 /// Build the upstream URL. DeepSeek strict function calls are a beta feature,
215 /// so only requests that actually carry `function.strict = true` preserve the
216 /// configured `/beta` route. Ordinary requests continue to use `/v1`.
217 fn upstream_url(endpoint: &ResolvedModelEndpoint, body: &Value) -> String {
218 let base = endpoint.base_url.trim_end_matches('/');
219 match endpoint.path_suffix.as_deref() {
220 Some(suffix) if !suffix.trim().is_empty() => format!(
221 "{}/{}",
222 unversioned_base_url(base),
223 suffix.trim_start_matches('/')
224 ),
225 _ => {
226 let mut versioned = versioned_base_url(base);
227 let deepseek_strict_beta = endpoint.provider == ProviderKind::Deepseek
228 && provider_base_url_is_official(endpoint.provider, base)
229 && versioned
230 .rsplit('/')
231 .next()
232 .is_some_and(|segment| segment.eq_ignore_ascii_case("beta"))
233 && body_uses_strict_tools(body);
234 if !deepseek_strict_beta
235 && versioned
236 .rsplit('/')
237 .next()
238 .is_some_and(|segment| segment.eq_ignore_ascii_case("beta"))
239 {
240 versioned = format!("{}/v1", unversioned_base_url(base));
241 }
242 format!("{}/chat/completions", versioned.trim_end_matches('/'))
243 }
244 }
245 }
246
247 fn body_uses_strict_tools(body: &Value) -> bool {
248 body.get("tools")
249 .and_then(Value::as_array)
250 .is_some_and(|tools| {
251 tools
252 .iter()
253 .any(|tool| tool.pointer("/function/strict").and_then(Value::as_bool) == Some(true))
254 })
255 }
256
257 fn versioned_base_url(base_url: &str) -> String {
258 let trimmed = base_url.trim_end_matches('/');
259 if base_url_has_version_suffix(trimmed) {
260 trimmed.to_string()
261 } else {
262 format!("{trimmed}/v1")
263 }
264 }
265
266 fn unversioned_base_url(base_url: &str) -> String {
267 let trimmed = base_url.trim_end_matches('/');
268 trimmed
269 .rsplit_once('/')
270 .filter(|(_, segment)| is_version_segment(segment))
271 .map(|(base, _)| base)
272 .unwrap_or(trimmed)
273 .to_string()
274 }
275
276 fn base_url_has_version_suffix(trimmed: &str) -> bool {
277 trimmed.rsplit('/').next().is_some_and(is_version_segment)
278 }
279
280 fn is_version_segment(segment: &str) -> bool {
281 segment.eq_ignore_ascii_case("beta")
282 || segment
283 .strip_prefix('v')
284 .or_else(|| segment.strip_prefix('V'))
285 .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
286 }
287
288 // ── Route handler ──────────────────────────────────────────────────────
289
290 pub(crate) async fn chat_completions_handler(
291 State(state): State<AppState>,
292 Json(mut body): Json<Value>,
293 ) -> impl IntoResponse {
294 // Reject streaming early.
295 if body
296 .get("stream")
297 .and_then(|v| v.as_bool())
298 .unwrap_or(false)
299 {
300 return (
301 StatusCode::BAD_REQUEST,
302 Json(serde_json::json!({
303 "error": {
304 "message": "streaming is not supported on this endpoint",
305 "type": "unsupported_parameter",
306 "code": "streaming_unsupported"
307 }
308 })),
309 )
310 .into_response();
311 }
312
313 // Extract model from body.
314 let request_model = body.get("model").and_then(|v| v.as_str());
315
316 // Resolve endpoint.
317 let config = state.config.read().await;
318 let vendor = config
319 .providers
320 .for_provider(config.provider)
321 .vendor
322 .as_deref()
323 .unwrap_or_default();
324 let openrouter_vendor = match validate_openrouter_vendor(vendor) {
325 Ok(vendor) if vendor.is_none() || config.provider == ProviderKind::Openrouter => vendor,
326 _ => {
327 return (
328 StatusCode::BAD_REQUEST,
329 Json(serde_json::json!({
330 "error": {
331 "message": "vendor is supported only for OpenRouter and must be a slug without whitespace or control characters",
332 "type": "invalid_request_error",
333 "code": "invalid_vendor"
334 }
335 })),
336 )
337 .into_response();
338 }
339 };
340 let endpoint = match resolve_endpoint(&config, &state.registry, request_model) {
341 Ok(endpoint) => endpoint,
342 Err(error) => {
343 return (
344 StatusCode::BAD_REQUEST,
345 Json(serde_json::json!({
346 "error": {
347 "message": format!("model route could not be resolved: {error}"),
348 "type": "invalid_request_error",
349 "code": "model_route_invalid"
350 }
351 })),
352 )
353 .into_response();
354 }
355 };
356
357 // Only ChatCompletions providers are supported.
358 if endpoint.wire_format != WireFormat::ChatCompletions {
359 return (
360 StatusCode::BAD_REQUEST,
361 Json(serde_json::json!({
362 "error": {
363 "message": format!(
364 "provider {:?} uses {:?} wire format, only ChatCompletions is supported",
365 endpoint.provider, endpoint.wire_format
366 ),
367 "type": "unsupported_provider",
368 "code": "provider_wire_format_unsupported"
369 }
370 })),
371 )
372 .into_response();
373 }
374
375 // Always write the resolved model back. Unknown provider-owned ids remain
376 // byte-for-byte passthrough values, while known aliases become their exact
377 // provider wire ids before forwarding.
378 body["model"] = serde_json::Value::String(endpoint.model.clone());
379 // The operator pin overrides caller ordering/fallback preferences while
380 // retaining caller restrictions such as only, ignore, and privacy policy.
381 apply_openrouter_vendor(&mut body, openrouter_vendor);
382
383 let url = upstream_url(&endpoint, &body);
384
385 if endpoint.insecure_skip_tls_verify {
386 return (
387 StatusCode::BAD_REQUEST,
388 Json(serde_json::json!({
389 "error": {
390 "message": format!(
391 "TLS certificate verification cannot be disabled for provider {:?}; use SSL_CERT_FILE with a trusted custom CA bundle",
392 endpoint.provider
393 ),
394 "type": "invalid_request_error",
395 "code": "tls_verification_required"
396 }
397 })),
398 )
399 .into_response();
400 }
401
402 // Build upstream request.
403 let upstream_req = codewhale_release::platform_http_client_builder()
404 .build()
405 .map_err(|e| {
406 (
407 StatusCode::INTERNAL_SERVER_ERROR,
408 Json(serde_json::json!({
409 "error": {
410 "message": format!("failed to build upstream client: {e}"),
411 "type": "internal_error"
412 }
413 })),
414 )
415 .into_response()
416 })
417 .map(|client| {
418 let mut req = client.post(&url).json(&body);
419
420 if !endpoint.auth_disabled
421 && let Some(key) = endpoint.api_key.as_deref()
422 {
423 req = req.bearer_auth(key);
424 }
425
426 // Forward configured provider headers.
427 for (name, value) in &endpoint.http_headers {
428 if endpoint.auth_disabled && is_upstream_auth_header(name) {
429 continue;
430 }
431 if let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) {
432 req = req.header(header_name, value.as_str());
433 }
434 }
435
436 req
437 });
438
439 let client = match upstream_req {
440 Ok(client) => client,
441 Err(resp) => return resp,
442 };
443
444 // Execute upstream request.
445 match client.send().await {
446 Ok(upstream_resp) => {
447 let status = upstream_resp.status();
448 let headers = upstream_resp.headers().clone();
449 match upstream_resp.text().await {
450 Ok(body_text) => {
451 let mut response =
452 axum::response::Response::new(axum::body::Body::from(body_text));
453 *response.status_mut() = status;
454 // Forward relevant upstream headers.
455 if let Some(ct) = headers.get("content-type") {
456 response.headers_mut().insert("content-type", ct.clone());
457 }
458 response
459 }
460 Err(e) => (
461 StatusCode::BAD_GATEWAY,
462 Json(serde_json::json!({
463 "error": {
464 "message": format!("failed to read upstream response: {e}"),
465 "type": "upstream_error"
466 }
467 })),
468 )
469 .into_response(),
470 }
471 }
472 Err(e) => (
473 StatusCode::BAD_GATEWAY,
474 Json(serde_json::json!({
475 "error": {
476 "message": format!("upstream request failed: {e}"),
477 "type": "upstream_error"
478 }
479 })),
480 )
481 .into_response(),
482 }
483 }
484
485 // ── Tests ──────────────────────────────────────────────────────────────
486
487 #[cfg(test)]
488 mod tests {
489 use super::*;
490 use axum::body::Body;
491 use axum::http::{Method, Request};
492 use codewhale_config::provider::WireFormat;
493 use std::fs;
494 use tokio::sync::mpsc;
495 use tower::ServiceExt;
496
497 use super::super::{app_router, build_state};
498
499 fn install_crypto_provider() {
500 crate::install_test_crypto_provider();
501 }
502
503 /// Start a minimal upstream mock server that echoes back what it received.
504 async fn start_mock_upstream() -> (String, tokio::task::JoinHandle<()>) {
505 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
506 let addr = listener.local_addr().unwrap();
507 let base_url = format!("http://{}:{}", addr.ip(), addr.port());
508
509 let handle = tokio::spawn(async move {
510 let app = axum::Router::new()
511 .route("/v1/chat/completions", axum::routing::post(mock_handler));
512 axum::serve(listener, app).await.unwrap();
513 });
514
515 // Give the server a moment to start.
516 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
517
518 (base_url, handle)
519 }
520
521 async fn mock_handler(
522 headers: axum::http::HeaderMap,
523 Json(body): Json<Value>,
524 ) -> impl axum::response::IntoResponse {
525 let auth = headers
526 .get("authorization")
527 .and_then(|v| v.to_str().ok())
528 .unwrap_or("none");
529
530 let response_body = serde_json::json!({
531 "id": "chatcmpl-mock",
532 "object": "chat.completion",
533 "created": 1234567890,
534 "model": body.get("model").and_then(|v| v.as_str()).unwrap_or("unknown"),
535 "choices": [{
536 "index": 0,
537 "message": {
538 "role": "assistant",
539 "content": format!("echo: received {} messages, auth={auth}",
540 body.get("messages").and_then(|m| m.as_array()).map(|a| a.len()).unwrap_or(0))
541 },
542 "finish_reason": "stop"
543 }],
544 "usage": {
545 "prompt_tokens": 10,
546 "completion_tokens": 5,
547 "total_tokens": 15
548 }
549 });
550
551 (StatusCode::OK, Json(response_body))
552 }
553
554 async fn capturing_mock_handler(
555 axum::extract::State(captured): axum::extract::State<
556 mpsc::UnboundedSender<axum::http::HeaderMap>,
557 >,
558 headers: axum::http::HeaderMap,
559 body: Json<Value>,
560 ) -> impl axum::response::IntoResponse {
561 captured
562 .send(headers.clone())
563 .expect("capture upstream headers");
564 mock_handler(headers, body).await
565 }
566
567 async fn start_capturing_mock_upstream() -> (
568 String,
569 mpsc::UnboundedReceiver<axum::http::HeaderMap>,
570 tokio::task::JoinHandle<()>,
571 ) {
572 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
573 .await
574 .expect("bind capturing upstream");
575 let addr = listener.local_addr().expect("capturing upstream address");
576 let base_url = format!("http://{}:{}", addr.ip(), addr.port());
577 let (captured_tx, captured_rx) = mpsc::unbounded_channel();
578
579 let handle = tokio::spawn(async move {
580 let app = axum::Router::new()
581 .route(
582 "/v1/chat/completions",
583 axum::routing::post(capturing_mock_handler),
584 )
585 .with_state(captured_tx);
586 axum::serve(listener, app)
587 .await
588 .expect("serve capturing upstream");
589 });
590
591 (base_url, captured_rx, handle)
592 }
593
594 fn app_with_mock_upstream(
595 auth_token: Option<&str>,
596 mock_base_url: &str,
597 ) -> (axum::Router, tempfile::TempDir) {
598 app_with_mock_upstream_with_provider_extra(auth_token, mock_base_url, "")
599 }
600
601 fn app_with_mock_upstream_with_provider_extra(
602 auth_token: Option<&str>,
603 mock_base_url: &str,
604 provider_extra: &str,
605 ) -> (axum::Router, tempfile::TempDir) {
606 let tmp = tempfile::tempdir().expect("tempdir");
607 let config_path = tmp.path().join("config.toml");
608 let config_content = format!(
609 r#"
610 provider = "arcee"
611 api_key = "sk-deepseek-secret"
612
613 [providers.arcee]
614 base_url = "{mock_base_url}"
615 model = "trinity-large-thinking"
616 api_key = "arcee-configured-key"
617 {provider_extra}
618 "#
619 );
620 fs::write(&config_path, config_content).expect("write config");
621 let state = build_state(
622 Some(config_path),
623 auth_token.map(std::string::ToString::to_string),
624 )
625 .expect("state");
626 (app_router(state, &[]), tmp)
627 }
628
629 fn app_with_together_mock_upstream(mock_base_url: &str) -> (axum::Router, tempfile::TempDir) {
630 let tmp = tempfile::tempdir().expect("tempdir");
631 let config_path = tmp.path().join("config.toml");
632 let config_content = format!(
633 r#"
634 provider = "together"
635
636 [providers.together]
637 base_url = "{mock_base_url}"
638 api_key = "together-configured-key"
639 "#
640 );
641 fs::write(&config_path, config_content).expect("write config");
642 let state = build_state(Some(config_path), None).expect("state");
643 (app_router(state, &[]), tmp)
644 }
645
646 fn app_with_root_deepseek_mock_upstream(
647 mock_base_url: &str,
648 ) -> (axum::Router, tempfile::TempDir) {
649 let tmp = tempfile::tempdir().expect("tempdir");
650 let config_path = tmp.path().join("config.toml");
651 let config_content = format!(
652 r#"
653 provider = "deepseek"
654 api_key = "root-deepseek-key"
655 base_url = "{mock_base_url}"
656 default_text_model = "root-deepseek-model"
657 http_headers = {{ "X-Root-Route" = "kept" }}
658 "#
659 );
660 fs::write(&config_path, config_content).expect("write config");
661 let state = build_state(Some(config_path), None).expect("state");
662 (app_router(state, &[]), tmp)
663 }
664
665 fn app_with_auth_boundary_mock_upstream(
666 auth_token: &str,
667 mock_base_url: &str,
668 provider_api_key: &str,
669 auth_mode: Option<&str>,
670 include_configured_auth_headers: bool,
671 ) -> (axum::Router, tempfile::TempDir) {
672 let tmp = tempfile::tempdir().expect("tempdir");
673 let config_path = tmp.path().join("config.toml");
674 let auth_mode = auth_mode
675 .map(|mode| format!("auth_mode = {mode:?}"))
676 .unwrap_or_default();
677 let configured_auth_headers = if include_configured_auth_headers {
678 r#"http_headers = { aUtHoRiZaTiOn = "Bearer configured-header-secret", "X-API-Key" = "configured-x-key-secret", "Api-Key" = "configured-key-secret", "Proxy-Authorization" = "Basic configured-proxy-secret", "X-Auth-Token" = "configured-auth-token", "X-Access-Token" = "configured-access-token", "X-Goog-Api-Key" = "configured-google-key", Cookie = "session=secret", "X-Route-Metadata" = "safe" }"#
679 } else {
680 ""
681 };
682 let config_content = format!(
683 r#"
684 provider = "arcee"
685
686 [providers.arcee]
687 base_url = "{mock_base_url}"
688 model = "trinity-large-thinking"
689 api_key = {provider_api_key:?}
690 {auth_mode}
691 {configured_auth_headers}
692 "#
693 );
694 fs::write(&config_path, config_content).expect("write config");
695 let state = build_state(Some(config_path), Some(auth_token.to_string())).expect("state");
696 (app_router(state, &[]), tmp)
697 }
698
699 async fn response_body_json(response: axum::response::Response) -> Value {
700 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
701 .await
702 .expect("body bytes");
703 serde_json::from_slice(&bytes).expect("json response")
704 }
705
706 #[tokio::test]
707 async fn openrouter_vendor_forwarding_preserves_pin_and_caller_restrictions() {
708 install_crypto_provider();
709 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
710 let mock_url = format!("http://{}", listener.local_addr().unwrap());
711 let (captured_tx, mut captured_rx) = mpsc::unbounded_channel::<Value>();
712 let upstream = axum::Router::new().route(
713 "/v1/chat/completions",
714 axum::routing::post(move |Json(body): Json<Value>| {
715 let captured = captured_tx.clone();
716 async move {
717 captured.send(body).unwrap();
718 Json(serde_json::json!({"choices": []}))
719 }
720 }),
721 );
722 let upstream_task = tokio::spawn(async move {
723 axum::serve(listener, upstream).await.unwrap();
724 });
725
726 for (provider, vendor, status) in [
727 ("openrouter", "deepinfra/turbo", StatusCode::OK),
728 ("openrouter", "", StatusCode::OK),
729 ("openrouter", "bad vendor fixture", StatusCode::BAD_REQUEST),
730 ("arcee", "deepinfra/turbo", StatusCode::BAD_REQUEST),
731 ("arcee", "", StatusCode::OK),
732 ] {
733 let tmp = tempfile::tempdir().unwrap();
734 let config_path = tmp.path().join("config.toml");
735 let openrouter_vendor = if provider == "openrouter" {
736 vendor
737 } else {
738 "dormant/pin"
739 };
740 let arcee_vendor = if provider == "arcee" { vendor } else { "" };
741 fs::write(&config_path, format!(
742 "provider = {provider:?}\n\
743 [providers.openrouter]\nbase_url = {mock_url:?}\napi_key = \"fixture-openrouter-key\"\nvendor = {openrouter_vendor:?}\n\
744 [providers.arcee]\nbase_url = {mock_url:?}\napi_key = \"fixture-arcee-key\"\nvendor = {arcee_vendor:?}\n"
745 )).unwrap();
746 let state = build_state(Some(config_path), None).unwrap();
747 let app = app_router(state, &[]);
748 let caller_policy = serde_json::json!({
749 "order": ["caller/escape"],
750 "allow_fallbacks": true,
751 "only": ["caller/restriction"],
752 "ignore": ["caller/blocked"],
753 "zdr": true,
754 "data_collection": "deny",
755 "require_parameters": true
756 });
757 let body = serde_json::json!({
758 "model": "fixture/model",
759 "messages": [{"role": "user", "content": "hello"}],
760 "provider": caller_policy
761 });
762 let response = app
763 .oneshot(
764 Request::builder()
765 .method(Method::POST)
766 .uri("/v1/chat/completions")
767 .header("content-type", "application/json")
768 .body(Body::from(serde_json::to_vec(&body).unwrap()))
769 .unwrap(),
770 )
771 .await
772 .unwrap();
773 assert_eq!(response.status(), status, "{provider}: {vendor}");
774 if status == StatusCode::BAD_REQUEST {
775 let error = response_body_json(response).await;
776 assert_eq!(error["error"]["code"], "invalid_vendor");
777 assert!(!error.to_string().contains(vendor));
778 assert!(
779 captured_rx.try_recv().is_err(),
780 "invalid config reached upstream"
781 );
782 } else {
783 let forwarded = captured_rx.try_recv().expect("captured forwarded request");
784 let mut expected = caller_policy;
785 if provider == "openrouter" && !vendor.is_empty() {
786 expected["order"] = serde_json::json!([vendor]);
787 expected["allow_fallbacks"] = serde_json::json!(false);
788 }
789 assert_eq!(forwarded["provider"], expected, "{provider}: {vendor}");
790 assert_eq!(forwarded["model"], "fixture/model");
791 }
792 }
793 upstream_task.abort();
794 }
795
796 #[tokio::test]
797 async fn forwards_messages_and_tools() {
798 install_crypto_provider();
799 let (mock_url, _mock) = start_mock_upstream().await;
800 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
801
802 let body = serde_json::json!({
803 "model": "trinity-large-thinking",
804 "messages": [
805 {"role": "user", "content": "hello"}
806 ],
807 "tools": [{
808 "type": "function",
809 "function": {
810 "name": "get_weather",
811 "description": "Get weather",
812 "parameters": {"type": "object", "properties": {}}
813 }
814 }],
815 "tool_choice": "auto"
816 });
817
818 let response = app
819 .oneshot(
820 Request::builder()
821 .method(Method::POST)
822 .uri("/v1/chat/completions")
823 .header("content-type", "application/json")
824 .body(Body::from(serde_json::to_vec(&body).unwrap()))
825 .unwrap(),
826 )
827 .await
828 .unwrap();
829
830 assert_eq!(response.status(), StatusCode::OK);
831 let resp_body = response_body_json(response).await;
832 assert_eq!(resp_body["model"], "trinity-large-thinking");
833 assert!(
834 resp_body["choices"][0]["message"]["content"]
835 .as_str()
836 .unwrap()
837 .contains("1 messages")
838 );
839 }
840
841 #[tokio::test]
842 async fn default_model_injected_when_omitted() {
843 install_crypto_provider();
844 let (mock_url, _mock) = start_mock_upstream().await;
845 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
846
847 let body = serde_json::json!({
848 "messages": [
849 {"role": "user", "content": "hello"}
850 ]
851 });
852
853 let response = app
854 .oneshot(
855 Request::builder()
856 .method(Method::POST)
857 .uri("/v1/chat/completions")
858 .header("content-type", "application/json")
859 .body(Body::from(serde_json::to_vec(&body).unwrap()))
860 .unwrap(),
861 )
862 .await
863 .unwrap();
864
865 assert_eq!(response.status(), StatusCode::OK);
866 let resp_body = response_body_json(response).await;
867 // The mock echoes the model it received; should be the configured default.
868 assert_eq!(resp_body["model"], "trinity-large-thinking");
869 }
870
871 #[tokio::test]
872 async fn root_deepseek_compatibility_fields_reach_the_configured_upstream() {
873 install_crypto_provider();
874 let (mock_url, mut captured, _mock) = start_capturing_mock_upstream().await;
875 let (app, _tmp) = app_with_root_deepseek_mock_upstream(&mock_url);
876
877 let body = serde_json::json!({
878 "messages": [{"role": "user", "content": "hello"}]
879 });
880 let response = app
881 .oneshot(
882 Request::builder()
883 .method(Method::POST)
884 .uri("/v1/chat/completions")
885 .header("content-type", "application/json")
886 .body(Body::from(serde_json::to_vec(&body).unwrap()))
887 .unwrap(),
888 )
889 .await
890 .unwrap();
891
892 assert_eq!(response.status(), StatusCode::OK);
893 let response_body = response_body_json(response).await;
894 assert_eq!(response_body["model"], "root-deepseek-model");
895 assert!(
896 response_body["choices"][0]["message"]["content"]
897 .as_str()
898 .is_some_and(|content| content.contains("auth=Bearer root-deepseek-key"))
899 );
900 let headers = tokio::time::timeout(std::time::Duration::from_secs(1), captured.recv())
901 .await
902 .expect("upstream request timeout")
903 .expect("captured upstream request");
904 assert_eq!(
905 headers
906 .get("x-root-route")
907 .and_then(|value| value.to_str().ok()),
908 Some("kept")
909 );
910 }
911
912 #[tokio::test]
913 async fn configured_model_preserved_when_provided() {
914 install_crypto_provider();
915 let (mock_url, _mock) = start_mock_upstream().await;
916 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
917
918 let body = serde_json::json!({
919 "model": "custom-model-v2",
920 "messages": [
921 {"role": "user", "content": "hello"}
922 ]
923 });
924
925 let response = app
926 .oneshot(
927 Request::builder()
928 .method(Method::POST)
929 .uri("/v1/chat/completions")
930 .header("content-type", "application/json")
931 .body(Body::from(serde_json::to_vec(&body).unwrap()))
932 .unwrap(),
933 )
934 .await
935 .unwrap();
936
937 assert_eq!(response.status(), StatusCode::OK);
938 let resp_body = response_body_json(response).await;
939 assert_eq!(resp_body["model"], "custom-model-v2");
940 }
941
942 #[test]
943 fn providerless_together_aliases_are_rejected_under_deepseek_authority() {
944 let config = ConfigToml::default();
945 let registry = ModelRegistry::default();
946
947 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
948 assert!(
949 matches!(
950 resolve_endpoint(&config, &registry, Some(requested)),
951 Err(RouteError::ForeignModelForDirectProvider { .. })
952 ),
953 "model text must not switch the configured DeepSeek route to Together: {requested}"
954 );
955 }
956 }
957
958 #[test]
959 fn shared_alias_stays_inside_the_configured_provider() {
960 let config = ConfigToml {
961 provider: ProviderKind::Openrouter,
962 ..ConfigToml::default()
963 };
964
965 let endpoint =
966 resolve_endpoint(&config, &ModelRegistry::default(), Some("deepseek-v4-pro"))
967 .expect("configured-provider route");
968
969 assert_eq!(endpoint.provider, ProviderKind::Openrouter);
970 }
971
972 #[test]
973 fn unknown_model_under_zai_never_infers_deepseek_provider_authority() {
974 let config = ConfigToml {
975 provider: ProviderKind::Zai,
976 ..ConfigToml::default()
977 };
978 let registry = ModelRegistry::default();
979
980 let endpoint = resolve_endpoint(&config, &registry, Some("totally-unknown-model"))
981 .expect("unknown future id stays inside explicit Z.ai authority");
982 assert_eq!(endpoint.provider, ProviderKind::Zai);
983 assert_eq!(endpoint.model, "totally-unknown-model");
984 assert_eq!(endpoint.api_key, None);
985 }
986
987 #[test]
988 fn known_deepseek_model_under_zai_is_rejected_instead_of_switching_credentials() {
989 let mut config = ConfigToml {
990 provider: ProviderKind::Zai,
991 ..ConfigToml::default()
992 };
993 config.providers.zai.api_key = Some("zai-only-key".to_string());
994 config.providers.deepseek.api_key = Some("must-not-be-selected".to_string());
995
996 assert!(matches!(
997 resolve_endpoint(
998 &config,
999 &ModelRegistry::default(),
1000 Some("deepseek-reasoner")
1001 ),
1002 Err(RouteError::ForeignModelForDirectProvider { .. })
1003 ));
1004 }
1005
1006 #[test]
1007 fn configured_together_authority_canonicalizes_its_own_inkling_aliases() {
1008 let config = ConfigToml {
1009 provider: ProviderKind::Together,
1010 ..ConfigToml::default()
1011 };
1012
1013 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
1014 let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some(requested))
1015 .expect("configured Together route");
1016 assert_eq!(endpoint.provider, ProviderKind::Together, "{requested}");
1017 assert_eq!(endpoint.model, "thinkingmachines/inkling", "{requested}");
1018 }
1019 }
1020
1021 #[test]
1022 fn configured_provider_is_required_for_each_official_alias() {
1023 let registry = ModelRegistry::default();
1024
1025 for (requested, provider, expected) in [
1026 (
1027 "qwen3.7-plus",
1028 ProviderKind::Openrouter,
1029 "qwen/qwen3.7-plus",
1030 ),
1031 ("gpt53-codex", ProviderKind::Openai, "gpt-5.3-codex"),
1032 ("arcee-trinity-mini", ProviderKind::Arcee, "trinity-mini"),
1033 ] {
1034 let config = ConfigToml {
1035 provider,
1036 ..ConfigToml::default()
1037 };
1038 let endpoint = resolve_endpoint(&config, &registry, Some(requested))
1039 .expect("provider-owned known alias route");
1040 assert_eq!(endpoint.provider, provider, "{requested}");
1041 assert_eq!(endpoint.model, expected, "{requested}");
1042 }
1043 }
1044
1045 #[test]
1046 fn default_deepseek_route_cannot_claim_foreign_official_aliases() {
1047 let config = ConfigToml::default();
1048
1049 for requested in ["qwen3.7-plus", "gpt53-codex", "arcee-trinity-mini"] {
1050 assert!(
1051 matches!(
1052 resolve_endpoint(&config, &ModelRegistry::default(), Some(requested)),
1053 Err(RouteError::ForeignModelForDirectProvider { .. })
1054 ),
1055 "{requested} must not select another provider from model text"
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn opencode_go_app_route_uses_model_protocol_without_cross_provider_fallback() {
1062 let registry = ModelRegistry::default();
1063 for (model, wire) in [
1064 ("grok-4.5", WireFormat::ChatCompletions),
1065 ("kimi-k3", WireFormat::ChatCompletions),
1066 ("grok-4.6", WireFormat::Responses),
1067 ("gpt-5.6-luna", WireFormat::Responses),
1068 ("minimax-m3", WireFormat::AnthropicMessages),
1069 ("qwen3.8-max", WireFormat::AnthropicMessages),
1070 ] {
1071 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1072 for base_url in [None, Some("https://go-gateway.example.test/v1".into())] {
1073 let mut config = ConfigToml {
1074 provider: ProviderKind::OpencodeGo,
1075 ..ConfigToml::default()
1076 };
1077 config.providers.opencode_go.model = Some(requested.clone());
1078 config.providers.opencode_go.base_url = base_url;
1079 for selection in [None, Some(requested.as_str())] {
1080 let endpoint = resolve_endpoint(&config, &registry, selection)
1081 .expect("documented Go route");
1082 assert_eq!(endpoint.provider, ProviderKind::OpencodeGo);
1083 assert_eq!(endpoint.model, model);
1084 assert_eq!(endpoint.wire_format, wire);
1085 }
1086 }
1087 }
1088 }
1089 for model in ["claude-unproven", "gpt-unlisted", "openai/gpt-5.6-luna"] {
1090 let mut config = ConfigToml {
1091 provider: ProviderKind::OpencodeGo,
1092 ..ConfigToml::default()
1093 };
1094 config.providers.opencode_go.model = Some(model.into());
1095 assert!(resolve_endpoint(&config, &registry, None).is_err());
1096 assert!(resolve_endpoint(&config, &registry, Some(model)).is_err());
1097 config.providers.opencode_go.base_url =
1098 Some("https://go-gateway.example.test/v1".into());
1099 assert!(resolve_endpoint(&config, &registry, Some(model)).is_err());
1100 }
1101 }
1102
1103 #[test]
1104 fn opencode_zen_app_route_uses_the_resolved_model_protocol() {
1105 let config = ConfigToml {
1106 provider: ProviderKind::OpencodeZen,
1107 ..ConfigToml::default()
1108 };
1109 let registry = ModelRegistry::default();
1110
1111 for (model, expected) in [
1112 ("gpt-5.5", WireFormat::Responses),
1113 ("claude-sonnet-4-6", WireFormat::AnthropicMessages),
1114 ("deepseek-v4-pro", WireFormat::ChatCompletions),
1115 ] {
1116 let endpoint = resolve_endpoint(&config, &registry, Some(model))
1117 .unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
1118 assert_eq!(endpoint.provider, ProviderKind::OpencodeZen);
1119 assert_eq!(endpoint.model, model);
1120 assert_eq!(endpoint.wire_format, expected);
1121 }
1122
1123 assert!(matches!(
1124 resolve_endpoint(&config, &registry, Some("gemini-3.1-pro")),
1125 Err(RouteError::UnsupportedModelProtocol { .. })
1126 ));
1127 }
1128
1129 #[test]
1130 fn foreign_model_is_rejected_before_credentials_or_headers_can_cross() {
1131 let mut config = ConfigToml {
1132 provider: ProviderKind::Deepseek,
1133 auth_mode: Some("none".to_string()),
1134 ..ConfigToml::default()
1135 };
1136 config.http_headers.insert(
1137 "X-Root-Route".to_string(),
1138 "must-not-cross-providers".to_string(),
1139 );
1140 config.providers.together.api_key = Some("together-key".to_string());
1141
1142 assert!(matches!(
1143 resolve_endpoint(&config, &ModelRegistry::default(), Some("inkling")),
1144 Err(RouteError::ForeignModelForDirectProvider { .. })
1145 ));
1146 }
1147
1148 #[test]
1149 fn every_official_deepseek_endpoint_canonicalizes_retired_aliases() {
1150 let registry = ModelRegistry::default();
1151 for base_url in [
1152 "https://api.deepseek.com",
1153 "https://api.deepseek.com/v1/",
1154 "https://api.deepseek.com/beta",
1155 ] {
1156 for alias in ["deepseek-chat", "deepseek-reasoner"] {
1157 let mut config = ConfigToml::default();
1158 config.providers.deepseek.base_url = Some(base_url.to_string());
1159 let endpoint = resolve_endpoint(&config, &registry, Some(alias))
1160 .expect("official DeepSeek route");
1161 assert_eq!(endpoint.provider, ProviderKind::Deepseek, "{base_url}");
1162 assert_eq!(endpoint.model, "deepseek-v4-flash", "{base_url} {alias}");
1163 }
1164 }
1165 }
1166
1167 #[test]
1168 fn custom_endpoint_preserves_known_registry_alias_verbatim() {
1169 let mut config = ConfigToml {
1170 provider: ProviderKind::Openrouter,
1171 ..ConfigToml::default()
1172 };
1173 config
1174 .providers
1175 .for_provider_mut(ProviderKind::Openrouter)
1176 .base_url = Some("https://gateway.example.test/v1".to_string());
1177
1178 let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("qwen3.7-plus"))
1179 .expect("custom OpenRouter-compatible route");
1180 assert_eq!(endpoint.provider, ProviderKind::Openrouter);
1181 assert_eq!(endpoint.model, "qwen3.7-plus");
1182 }
1183
1184 #[test]
1185 fn custom_endpoint_never_resolves_ambient_provider_env() {
1186 let ambient_was_read = std::cell::Cell::new(false);
1187 let api_key = resolve_upstream_api_key(None, false, false, || {
1188 ambient_was_read.set(true);
1189 Some("ambient-provider-secret".to_string())
1190 });
1191
1192 assert_eq!(api_key, None);
1193 assert!(!ambient_was_read.get());
1194 for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
1195 assert_eq!(
1196 resolve_upstream_api_key(Some(sentinel), false, false, || unreachable!()),
1197 None
1198 );
1199 assert_eq!(
1200 resolve_upstream_api_key(Some(sentinel), false, true, || Some("ambient".into())),
1201 Some("ambient".to_string())
1202 );
1203 }
1204 }
1205
1206 #[test]
1207 fn disabled_auth_never_resolves_configured_or_ambient_credentials() {
1208 let ambient_was_read = std::cell::Cell::new(false);
1209 let api_key = resolve_upstream_api_key(Some("provider-secret"), true, true, || {
1210 ambient_was_read.set(true);
1211 Some("ambient-provider-secret".to_string())
1212 });
1213
1214 assert_eq!(api_key, None);
1215 assert!(!ambient_was_read.get());
1216 }
1217
1218 #[test]
1219 fn active_custom_endpoint_is_not_hijacked_by_known_foreign_alias() {
1220 let mut config = ConfigToml {
1221 provider: ProviderKind::Arcee,
1222 ..ConfigToml::default()
1223 };
1224 config
1225 .providers
1226 .for_provider_mut(ProviderKind::Arcee)
1227 .base_url = Some("https://gateway.example.test/v1".to_string());
1228
1229 let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("qwen3.7-plus"))
1230 .expect("active custom endpoint route");
1231 assert_eq!(endpoint.provider, ProviderKind::Arcee);
1232 assert_eq!(endpoint.model, "qwen3.7-plus");
1233 }
1234
1235 #[test]
1236 fn official_configured_alias_is_canonicalized_when_model_is_omitted() {
1237 let mut config = ConfigToml {
1238 provider: ProviderKind::Openrouter,
1239 ..ConfigToml::default()
1240 };
1241 config
1242 .providers
1243 .for_provider_mut(ProviderKind::Openrouter)
1244 .model = Some("qwen3.7-plus".to_string());
1245
1246 let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), None)
1247 .expect("configured official alias route");
1248 assert_eq!(endpoint.provider, ProviderKind::Openrouter);
1249 assert_eq!(endpoint.model, "qwen/qwen3.7-plus");
1250 }
1251
1252 #[test]
1253 fn configured_together_inkling_alias_is_normalized_when_model_is_omitted() {
1254 let mut config = ConfigToml {
1255 provider: ProviderKind::Together,
1256 ..ConfigToml::default()
1257 };
1258 config
1259 .providers
1260 .for_provider_mut(ProviderKind::Together)
1261 .model = Some("inkling".to_string());
1262
1263 let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), None)
1264 .expect("configured Inkling route");
1265 assert_eq!(endpoint.provider, ProviderKind::Together);
1266 assert_eq!(endpoint.model, "thinkingmachines/inkling");
1267 }
1268
1269 #[tokio::test]
1270 async fn custom_together_endpoint_preserves_explicit_inkling_model_ids() {
1271 install_crypto_provider();
1272 let (mock_url, _mock) = start_mock_upstream().await;
1273 let (app, _tmp) = app_with_together_mock_upstream(&mock_url);
1274
1275 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
1276 let body = serde_json::json!({
1277 "model": requested,
1278 "messages": [{"role": "user", "content": "hello"}]
1279 });
1280 let response = app
1281 .clone()
1282 .oneshot(
1283 Request::builder()
1284 .method(Method::POST)
1285 .uri("/v1/chat/completions")
1286 .header("content-type", "application/json")
1287 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1288 .unwrap(),
1289 )
1290 .await
1291 .unwrap();
1292
1293 assert_eq!(response.status(), StatusCode::OK, "{requested}");
1294 let resp_body = response_body_json(response).await;
1295 assert_eq!(resp_body["model"], requested, "{requested}");
1296 }
1297 }
1298
1299 #[tokio::test]
1300 async fn configured_api_key_takes_priority_over_incoming_bearer() {
1301 install_crypto_provider();
1302 let (mock_url, _mock) = start_mock_upstream().await;
1303 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
1304
1305 let body = serde_json::json!({
1306 "model": "trinity-large-thinking",
1307 "messages": [
1308 {"role": "user", "content": "hello"}
1309 ]
1310 });
1311
1312 // Send with an explicit bearer token, but the configured key should win.
1313 let response = app
1314 .oneshot(
1315 Request::builder()
1316 .method(Method::POST)
1317 .uri("/v1/chat/completions")
1318 .header("content-type", "application/json")
1319 .header("authorization", "Bearer user-provided-secret-key")
1320 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1321 .unwrap(),
1322 )
1323 .await
1324 .unwrap();
1325
1326 assert_eq!(response.status(), StatusCode::OK);
1327 let resp_body = response_body_json(response).await;
1328 let content = resp_body["choices"][0]["message"]["content"]
1329 .as_str()
1330 .unwrap();
1331 // The configured key takes priority, not the incoming Bearer.
1332 assert!(
1333 content.contains("auth=Bearer arcee-configured-key"),
1334 "expected configured auth in mock echo, got: {content}"
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn app_authorization_is_not_forwarded_when_upstream_auth_is_disabled() {
1340 install_crypto_provider();
1341 let (mock_url, mut captured, _mock) = start_capturing_mock_upstream().await;
1342 let (app, _tmp) = app_with_auth_boundary_mock_upstream(
1343 "app-secret",
1344 &mock_url,
1345 "provider-secret",
1346 Some("none"),
1347 true,
1348 );
1349
1350 let body = serde_json::json!({
1351 "model": "trinity-large-thinking",
1352 "messages": [{"role": "user", "content": "hello"}]
1353 });
1354 let response = app
1355 .oneshot(
1356 Request::builder()
1357 .method(Method::POST)
1358 .uri("/v1/chat/completions")
1359 .header("content-type", "application/json")
1360 .header("authorization", "Bearer app-secret")
1361 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1362 .unwrap(),
1363 )
1364 .await
1365 .unwrap();
1366
1367 assert_eq!(response.status(), StatusCode::OK);
1368 let headers = tokio::time::timeout(std::time::Duration::from_secs(1), captured.recv())
1369 .await
1370 .expect("upstream request timeout")
1371 .expect("captured upstream request");
1372 for name in [
1373 "authorization",
1374 "x-api-key",
1375 "api-key",
1376 "proxy-authorization",
1377 "x-auth-token",
1378 "x-access-token",
1379 "x-goog-api-key",
1380 "cookie",
1381 ] {
1382 assert!(headers.get(name).is_none(), "disabled auth leaked {name}");
1383 }
1384 assert_eq!(
1385 headers
1386 .get("x-route-metadata")
1387 .and_then(|value| value.to_str().ok()),
1388 Some("safe")
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn configured_provider_credential_is_the_only_outbound_bearer() {
1394 install_crypto_provider();
1395 let (mock_url, mut captured, _mock) = start_capturing_mock_upstream().await;
1396 let (app, _tmp) = app_with_auth_boundary_mock_upstream(
1397 "app-secret",
1398 &mock_url,
1399 "provider-secret",
1400 None,
1401 false,
1402 );
1403
1404 let body = serde_json::json!({
1405 "model": "trinity-large-thinking",
1406 "messages": [{"role": "user", "content": "hello"}]
1407 });
1408 let response = app
1409 .oneshot(
1410 Request::builder()
1411 .method(Method::POST)
1412 .uri("/v1/chat/completions")
1413 .header("content-type", "application/json")
1414 .header("authorization", "Bearer app-secret")
1415 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1416 .unwrap(),
1417 )
1418 .await
1419 .unwrap();
1420
1421 assert_eq!(response.status(), StatusCode::OK);
1422 let headers = tokio::time::timeout(std::time::Duration::from_secs(1), captured.recv())
1423 .await
1424 .expect("upstream request timeout")
1425 .expect("captured upstream request");
1426 assert_eq!(
1427 headers
1428 .get("authorization")
1429 .and_then(|value| value.to_str().ok()),
1430 Some("Bearer provider-secret")
1431 );
1432 }
1433
1434 #[tokio::test]
1435 async fn configured_api_key_used_when_no_bearer_in_request() {
1436 install_crypto_provider();
1437 let (mock_url, _mock) = start_mock_upstream().await;
1438 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
1439
1440 let body = serde_json::json!({
1441 "model": "trinity-large-thinking",
1442 "messages": [
1443 {"role": "user", "content": "hello"}
1444 ]
1445 });
1446
1447 // No Authorization header; the configured key should be used.
1448 let response = app
1449 .oneshot(
1450 Request::builder()
1451 .method(Method::POST)
1452 .uri("/v1/chat/completions")
1453 .header("content-type", "application/json")
1454 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1455 .unwrap(),
1456 )
1457 .await
1458 .unwrap();
1459
1460 assert_eq!(response.status(), StatusCode::OK);
1461 let resp_body = response_body_json(response).await;
1462 let content = resp_body["choices"][0]["message"]["content"]
1463 .as_str()
1464 .unwrap();
1465 assert!(
1466 content.contains("auth=Bearer arcee-configured-key"),
1467 "expected configured auth in mock echo, got: {content}"
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn insecure_tls_skip_verify_is_rejected() {
1473 install_crypto_provider();
1474 let (mock_url, _mock) = start_mock_upstream().await;
1475 let (app, _tmp) = app_with_mock_upstream_with_provider_extra(
1476 None,
1477 &mock_url,
1478 "insecure_skip_tls_verify = true",
1479 );
1480
1481 let body = serde_json::json!({
1482 "model": "trinity-large-thinking",
1483 "messages": [
1484 {"role": "user", "content": "hello"}
1485 ]
1486 });
1487
1488 let response = app
1489 .oneshot(
1490 Request::builder()
1491 .method(Method::POST)
1492 .uri("/v1/chat/completions")
1493 .header("content-type", "application/json")
1494 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1495 .unwrap(),
1496 )
1497 .await
1498 .unwrap();
1499
1500 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1501 let resp_body = response_body_json(response).await;
1502 assert_eq!(resp_body["error"]["code"], "tls_verification_required");
1503 assert!(
1504 resp_body["error"]["message"]
1505 .as_str()
1506 .unwrap()
1507 .contains("SSL_CERT_FILE")
1508 );
1509 }
1510
1511 #[tokio::test]
1512 async fn streaming_request_rejected() {
1513 install_crypto_provider();
1514 let (mock_url, _mock) = start_mock_upstream().await;
1515 let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
1516
1517 let body = serde_json::json!({
1518 "model": "trinity-large-thinking",
1519 "messages": [
1520 {"role": "user", "content": "hello"}
1521 ],
1522 "stream": true
1523 });
1524
1525 let response = app
1526 .oneshot(
1527 Request::builder()
1528 .method(Method::POST)
1529 .uri("/v1/chat/completions")
1530 .header("content-type", "application/json")
1531 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1532 .unwrap(),
1533 )
1534 .await
1535 .unwrap();
1536
1537 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1538 let resp_body = response_body_json(response).await;
1539 assert_eq!(resp_body["error"]["code"], "streaming_unsupported");
1540 }
1541
1542 #[tokio::test]
1543 async fn requires_bearer_token_when_auth_enabled() {
1544 install_crypto_provider();
1545 let (mock_url, _mock) = start_mock_upstream().await;
1546 let (app, _tmp) = app_with_mock_upstream(Some("test-token"), &mock_url);
1547
1548 let body = serde_json::json!({
1549 "messages": [{"role": "user", "content": "hello"}]
1550 });
1551
1552 let response = app
1553 .oneshot(
1554 Request::builder()
1555 .method(Method::POST)
1556 .uri("/v1/chat/completions")
1557 .header("content-type", "application/json")
1558 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1559 .unwrap(),
1560 )
1561 .await
1562 .unwrap();
1563
1564 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1565 }
1566
1567 #[tokio::test]
1568 async fn non_chat_completions_provider_rejected() {
1569 // Use the test to verify WireFormat checks work for non-ChatCompletions providers.
1570 // Anthropic's wire format is AnthropicMessages; OpenaiCodex is Responses.
1571 let endpoint = ResolvedModelEndpoint {
1572 provider: ProviderKind::Anthropic,
1573 base_url: "https://api.anthropic.com".to_string(),
1574 model: "claude-sonnet-4-20250514".to_string(),
1575 api_key: Some("sk-ant-test".to_string()),
1576 auth_disabled: false,
1577 http_headers: BTreeMap::new(),
1578 path_suffix: None,
1579 insecure_skip_tls_verify: false,
1580 wire_format: WireFormat::AnthropicMessages,
1581 };
1582
1583 assert_ne!(endpoint.wire_format, WireFormat::ChatCompletions);
1584 // The handler would reject this; we verify the wire format here.
1585 assert_eq!(endpoint.wire_format, WireFormat::AnthropicMessages);
1586 }
1587
1588 #[test]
1589 fn upstream_url_defaults_to_v1_chat_completions() {
1590 let endpoint = ResolvedModelEndpoint {
1591 provider: ProviderKind::Arcee,
1592 base_url: "https://api.arcee.ai".to_string(),
1593 model: "trinity".to_string(),
1594 api_key: None,
1595 auth_disabled: false,
1596 http_headers: BTreeMap::new(),
1597 path_suffix: None,
1598 insecure_skip_tls_verify: false,
1599 wire_format: WireFormat::ChatCompletions,
1600 };
1601 assert_eq!(
1602 upstream_url(&endpoint, &serde_json::json!({})),
1603 "https://api.arcee.ai/v1/chat/completions"
1604 );
1605 }
1606
1607 #[test]
1608 fn upstream_url_preserves_arcee_api_v1_base() {
1609 let endpoint = ResolvedModelEndpoint {
1610 provider: ProviderKind::Arcee,
1611 base_url: "https://api.arcee.ai/api/v1".to_string(),
1612 model: "trinity".to_string(),
1613 api_key: None,
1614 auth_disabled: false,
1615 http_headers: BTreeMap::new(),
1616 path_suffix: None,
1617 insecure_skip_tls_verify: false,
1618 wire_format: WireFormat::ChatCompletions,
1619 };
1620 assert_eq!(
1621 upstream_url(&endpoint, &serde_json::json!({})),
1622 "https://api.arcee.ai/api/v1/chat/completions"
1623 );
1624 }
1625
1626 #[test]
1627 fn upstream_url_respects_path_suffix() {
1628 let endpoint = ResolvedModelEndpoint {
1629 provider: ProviderKind::Openrouter,
1630 base_url: "https://openrouter.ai/api/v1".to_string(),
1631 model: "deepseek/deepseek-v4-pro".to_string(),
1632 api_key: None,
1633 auth_disabled: false,
1634 http_headers: BTreeMap::new(),
1635 path_suffix: Some("/chat/completions".to_string()),
1636 insecure_skip_tls_verify: false,
1637 wire_format: WireFormat::ChatCompletions,
1638 };
1639 assert_eq!(
1640 upstream_url(&endpoint, &serde_json::json!({})),
1641 "https://openrouter.ai/api/chat/completions"
1642 );
1643 }
1644
1645 #[test]
1646 fn upstream_url_beta_base_uses_v1_for_ordinary_chat_completions() {
1647 let endpoint = ResolvedModelEndpoint {
1648 provider: ProviderKind::Deepseek,
1649 base_url: "https://api.deepseek.com/beta".to_string(),
1650 model: "deepseek-chat".to_string(),
1651 api_key: None,
1652 auth_disabled: false,
1653 http_headers: BTreeMap::new(),
1654 path_suffix: None,
1655 insecure_skip_tls_verify: false,
1656 wire_format: WireFormat::ChatCompletions,
1657 };
1658 assert_eq!(
1659 upstream_url(&endpoint, &serde_json::json!({})),
1660 "https://api.deepseek.com/v1/chat/completions"
1661 );
1662 }
1663
1664 #[test]
1665 fn upstream_url_beta_base_preserves_strict_chat_completions() {
1666 let endpoint = ResolvedModelEndpoint {
1667 provider: ProviderKind::Deepseek,
1668 base_url: "https://api.deepseek.com/beta".to_string(),
1669 model: "deepseek-v4-pro".to_string(),
1670 api_key: None,
1671 auth_disabled: false,
1672 http_headers: BTreeMap::new(),
1673 path_suffix: None,
1674 insecure_skip_tls_verify: false,
1675 wire_format: WireFormat::ChatCompletions,
1676 };
1677 let body = serde_json::json!({
1678 "tools": [{
1679 "type": "function",
1680 "function": {
1681 "name": "lookup",
1682 "strict": true,
1683 "parameters": {"type": "object"}
1684 }
1685 }]
1686 });
1687
1688 assert_eq!(
1689 upstream_url(&endpoint, &body),
1690 "https://api.deepseek.com/beta/chat/completions"
1691 );
1692 }
1693
1694 #[test]
1695 fn upstream_url_strips_trailing_slash() {
1696 let endpoint = ResolvedModelEndpoint {
1697 provider: ProviderKind::Deepseek,
1698 base_url: "https://api.deepseek.com/".to_string(),
1699 model: "deepseek-chat".to_string(),
1700 api_key: None,
1701 auth_disabled: false,
1702 http_headers: BTreeMap::new(),
1703 path_suffix: None,
1704 insecure_skip_tls_verify: false,
1705 wire_format: WireFormat::ChatCompletions,
1706 };
1707 assert_eq!(
1708 upstream_url(&endpoint, &serde_json::json!({})),
1709 "https://api.deepseek.com/v1/chat/completions"
1710 );
1711 }
1712 }
1713
1713 lines RUST