2026-05-02 13:23:54 +08:00
|
|
|
use crate::ai_serving::GatewayControlDecision;
|
|
|
|
|
use crate::ai_serving::{
|
|
|
|
|
is_matching_stream_http_request as is_matching_stream_http_request_impl,
|
2026-07-27 09:36:31 +08:00
|
|
|
resolve_execution_runtime_stream_plan_kind_with_client_surface as resolve_execution_runtime_stream_plan_kind_impl,
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind_with_client_surface as resolve_execution_runtime_sync_plan_kind_impl,
|
2026-05-02 18:19:39 +08:00
|
|
|
supports_stream_execution_decision_kind as supports_stream_execution_decision_kind_impl,
|
|
|
|
|
supports_sync_execution_decision_kind as supports_sync_execution_decision_kind_impl,
|
2026-04-09 00:10:38 +08:00
|
|
|
};
|
2026-04-07 02:50:19 +08:00
|
|
|
|
|
|
|
|
pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
|
|
|
|
parts: &http::request::Parts,
|
|
|
|
|
decision: &GatewayControlDecision,
|
|
|
|
|
) -> Option<&'static str> {
|
2026-07-27 09:36:31 +08:00
|
|
|
let plan_kind = resolve_execution_runtime_stream_plan_kind_impl(
|
2026-04-07 02:50:19 +08:00
|
|
|
decision.route_class.as_deref(),
|
|
|
|
|
decision.route_family.as_deref(),
|
|
|
|
|
decision.route_kind.as_deref(),
|
2026-07-27 09:36:31 +08:00
|
|
|
decision.client_surface,
|
2026-05-02 21:23:33 +08:00
|
|
|
decision.request_auth_channel.as_deref(),
|
2026-04-07 02:50:19 +08:00
|
|
|
&parts.method,
|
|
|
|
|
parts.uri.path(),
|
2026-07-27 09:36:31 +08:00
|
|
|
)?;
|
|
|
|
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
|
|
|
|
.then_some(plan_kind)
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
|
|
|
|
parts: &http::request::Parts,
|
|
|
|
|
decision: &GatewayControlDecision,
|
|
|
|
|
) -> Option<&'static str> {
|
2026-07-27 09:36:31 +08:00
|
|
|
let plan_kind = resolve_execution_runtime_sync_plan_kind_impl(
|
2026-04-07 02:50:19 +08:00
|
|
|
decision.route_class.as_deref(),
|
|
|
|
|
decision.route_family.as_deref(),
|
|
|
|
|
decision.route_kind.as_deref(),
|
2026-07-27 09:36:31 +08:00
|
|
|
decision.client_surface,
|
2026-05-02 21:23:33 +08:00
|
|
|
decision.request_auth_channel.as_deref(),
|
2026-04-07 02:50:19 +08:00
|
|
|
&parts.method,
|
|
|
|
|
parts.uri.path(),
|
2026-07-27 09:36:31 +08:00
|
|
|
)?;
|
|
|
|
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
|
|
|
|
.then_some(plan_kind)
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn is_matching_stream_request(
|
|
|
|
|
plan_kind: &str,
|
|
|
|
|
parts: &http::request::Parts,
|
|
|
|
|
body_json: &serde_json::Value,
|
2026-04-23 14:42:51 +08:00
|
|
|
body_base64: Option<&str>,
|
2026-04-07 02:50:19 +08:00
|
|
|
) -> bool {
|
2026-05-02 13:23:54 +08:00
|
|
|
is_matching_stream_http_request_impl(plan_kind, parts, body_json, body_base64)
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:19:39 +08:00
|
|
|
pub(crate) fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
|
|
|
|
|
supports_sync_execution_decision_kind_impl(plan_kind)
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:19:39 +08:00
|
|
|
pub(crate) fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
|
|
|
|
|
supports_stream_execution_decision_kind_impl(plan_kind)
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use axum::http::{Method, Request};
|
2026-04-23 14:42:51 +08:00
|
|
|
use base64::Engine as _;
|
2026-04-07 02:50:19 +08:00
|
|
|
|
|
|
|
|
use super::{
|
|
|
|
|
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
2026-05-02 18:19:39 +08:00
|
|
|
resolve_execution_runtime_sync_plan_kind, supports_stream_execution_decision_kind,
|
|
|
|
|
supports_sync_execution_decision_kind,
|
2026-04-07 02:50:19 +08:00
|
|
|
};
|
2026-07-27 09:36:31 +08:00
|
|
|
use crate::ai_serving::{ApiOperation, ClientSurface, GatewayControlDecision};
|
2026-04-07 02:50:19 +08:00
|
|
|
|
|
|
|
|
fn sample_decision(route_family: &str, route_kind: &str) -> GatewayControlDecision {
|
|
|
|
|
GatewayControlDecision {
|
|
|
|
|
public_path: "/".to_string(),
|
|
|
|
|
public_query_string: None,
|
|
|
|
|
route_class: Some("ai_public".to_string()),
|
|
|
|
|
route_family: Some(route_family.to_string()),
|
|
|
|
|
route_kind: Some(route_kind.to_string()),
|
2026-07-27 09:36:31 +08:00
|
|
|
client_surface: None,
|
|
|
|
|
api_operation: None,
|
|
|
|
|
gateway_credential_carrier: None,
|
2026-05-02 21:23:33 +08:00
|
|
|
request_auth_channel: None,
|
2026-04-07 02:50:19 +08:00
|
|
|
auth_context: None,
|
|
|
|
|
admin_principal: None,
|
|
|
|
|
auth_endpoint_signature: None,
|
|
|
|
|
execution_runtime_candidate: true,
|
|
|
|
|
local_auth_rejection: None,
|
2026-07-11 03:42:30 +08:00
|
|
|
model_directive_policy: Default::default(),
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 21:23:33 +08:00
|
|
|
fn sample_decision_with_auth_channel(
|
|
|
|
|
route_family: &str,
|
|
|
|
|
route_kind: &str,
|
|
|
|
|
request_auth_channel: &str,
|
|
|
|
|
) -> GatewayControlDecision {
|
|
|
|
|
let mut decision = sample_decision(route_family, route_kind);
|
|
|
|
|
decision.request_auth_channel = Some(request_auth_channel.to_string());
|
|
|
|
|
decision
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 02:50:19 +08:00
|
|
|
#[test]
|
2026-05-02 18:19:39 +08:00
|
|
|
fn resolves_openai_chat_plan_kinds_via_format_crate() {
|
2026-04-07 02:50:19 +08:00
|
|
|
let request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1/chat/completions")
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (parts, _) = request.into_parts();
|
|
|
|
|
let decision = sample_decision("openai", "chat");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
|
|
|
|
Some("openai_chat_sync")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
|
|
|
|
Some("openai_chat_stream")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 21:23:33 +08:00
|
|
|
#[test]
|
|
|
|
|
fn resolves_endpoint_route_kinds_by_request_auth_channel_via_format_crate() {
|
|
|
|
|
let claude_request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1/messages")
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (claude_parts, _) = claude_request.into_parts();
|
|
|
|
|
|
|
|
|
|
let claude_api_key = sample_decision_with_auth_channel("claude", "messages", "api_key");
|
2026-07-27 09:36:31 +08:00
|
|
|
let mut claude_bearer =
|
|
|
|
|
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
|
|
|
|
claude_bearer.client_surface = Some(ClientSurface::ClaudeCode);
|
2026-05-02 21:23:33 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_api_key),
|
|
|
|
|
Some("claude_chat_sync")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_stream_plan_kind(&claude_parts, &claude_bearer),
|
|
|
|
|
Some("claude_cli_stream")
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-27 09:36:31 +08:00
|
|
|
let claude_sdk_bearer =
|
|
|
|
|
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_sdk_bearer),
|
|
|
|
|
Some("claude_chat_sync")
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-02 21:23:33 +08:00
|
|
|
let gemini_request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1beta/models/gemini-2.5-pro:generateContent")
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (gemini_parts, _) = gemini_request.into_parts();
|
|
|
|
|
|
|
|
|
|
let gemini_api_key =
|
|
|
|
|
sample_decision_with_auth_channel("gemini", "generate_content", "api_key");
|
|
|
|
|
let gemini_bearer =
|
|
|
|
|
sample_decision_with_auth_channel("gemini", "generate_content", "bearer_like");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&gemini_parts, &gemini_api_key),
|
|
|
|
|
Some("gemini_chat_sync")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&gemini_parts, &gemini_bearer),
|
|
|
|
|
Some("gemini_cli_sync")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 09:36:31 +08:00
|
|
|
#[test]
|
|
|
|
|
fn resolves_claude_count_tokens_as_native_sync_operation() {
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1/messages/count_tokens")
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (parts, _) = request.into_parts();
|
|
|
|
|
let mut decision = sample_decision("claude", "count_tokens");
|
|
|
|
|
decision.api_operation = Some(ApiOperation::ClaudeCountTokens);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
|
|
|
|
Some("claude_count_tokens_sync")
|
|
|
|
|
);
|
|
|
|
|
assert!(supports_sync_execution_decision_kind(
|
|
|
|
|
"claude_count_tokens_sync"
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
decision.api_operation = Some(ApiOperation::ClaudeMessagesCreate);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
|
|
|
|
None
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
|
|
|
|
None
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 02:50:19 +08:00
|
|
|
#[test]
|
2026-05-02 13:23:54 +08:00
|
|
|
fn stream_matching_uses_surface_route_logic() {
|
2026-04-07 02:50:19 +08:00
|
|
|
let request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1/chat/completions")
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (parts, _) = request.into_parts();
|
|
|
|
|
|
|
|
|
|
assert!(!is_matching_stream_request(
|
|
|
|
|
"openai_chat_stream",
|
|
|
|
|
&parts,
|
|
|
|
|
&serde_json::json!({"stream": false}),
|
2026-04-23 14:42:51 +08:00
|
|
|
None,
|
2026-04-07 02:50:19 +08:00
|
|
|
));
|
|
|
|
|
assert!(is_matching_stream_request(
|
|
|
|
|
"openai_chat_stream",
|
|
|
|
|
&parts,
|
|
|
|
|
&serde_json::json!({"stream": true}),
|
2026-04-23 14:42:51 +08:00
|
|
|
None,
|
2026-04-07 02:50:19 +08:00
|
|
|
));
|
2026-05-02 18:19:39 +08:00
|
|
|
assert!(supports_sync_execution_decision_kind("openai_chat_sync"));
|
|
|
|
|
assert!(supports_stream_execution_decision_kind(
|
2026-04-07 02:50:19 +08:00
|
|
|
"openai_chat_stream"
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-23 14:42:51 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn image_stream_matching_parses_multipart_stream_flag() {
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/v1/images/edits")
|
|
|
|
|
.header(
|
|
|
|
|
http::header::CONTENT_TYPE,
|
|
|
|
|
"multipart/form-data; boundary=image-stream-boundary",
|
|
|
|
|
)
|
|
|
|
|
.body(())
|
|
|
|
|
.expect("request should build");
|
|
|
|
|
let (parts, _) = request.into_parts();
|
|
|
|
|
let body = concat!(
|
|
|
|
|
"--image-stream-boundary\r\n",
|
|
|
|
|
"Content-Disposition: form-data; name=\"stream\"\r\n\r\n",
|
|
|
|
|
"true\r\n",
|
|
|
|
|
"--image-stream-boundary--\r\n"
|
|
|
|
|
);
|
|
|
|
|
let body_base64 = base64::engine::general_purpose::STANDARD.encode(body.as_bytes());
|
|
|
|
|
|
|
|
|
|
assert!(is_matching_stream_request(
|
|
|
|
|
"openai_image_stream",
|
|
|
|
|
&parts,
|
|
|
|
|
&serde_json::json!({}),
|
|
|
|
|
Some(body_base64.as_str()),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|