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