Files
Aether/crates/aether-provider/transport/src/url.rs
T
elky 579f2c7cc1 feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
2026-09-04 03:45:52 +08:00

966 lines
32 KiB
Rust

use std::collections::BTreeMap;
use url::form_urlencoded;
use url::Url;
pub(crate) const GATEWAY_CREDENTIAL_QUERY_KEYS: &[&str] = &["key"];
pub(crate) fn encode_url_path_segment(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-' | b'.'
| b'_'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b':'
| b'@'
)
{
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
encoded
}
pub(crate) fn strip_gateway_credential_query_parameters(query: Option<&str>) -> Option<String> {
let query = query.map(str::trim).filter(|value| !value.is_empty())?;
let mut serializer = form_urlencoded::Serializer::new(String::new());
let mut retained = false;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if GATEWAY_CREDENTIAL_QUERY_KEYS
.iter()
.any(|blocked| key.eq_ignore_ascii_case(blocked))
{
continue;
}
serializer.append_pair(&key, &value);
retained = true;
}
retained.then(|| serializer.finish())
}
pub fn build_openai_chat_url(upstream_base_url: &str, query: Option<&str>) -> String {
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
let trimmed = trimmed.trim_end_matches('/');
let mut url = format!("{trimmed}/chat/completions");
append_merged_query(&mut url, base_query, None, query, &[]);
url
}
pub fn build_openai_responses_url(
upstream_base_url: &str,
query: Option<&str>,
compact: bool,
) -> String {
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
let trimmed = trimmed.trim_end_matches('/');
let suffix = if compact {
"/responses/compact"
} else {
"/responses"
};
let mut url = format!("{trimmed}{suffix}");
append_merged_query(&mut url, base_query, None, query, &[]);
url
}
pub fn build_openai_search_url(upstream_base_url: &str, query: Option<&str>) -> String {
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
let trimmed = trimmed.trim_end_matches('/');
let mut url = format!("{trimmed}/alpha/search");
append_merged_query(&mut url, base_query, None, query, &[]);
url
}
pub fn build_openai_image_url(
upstream_base_url: &str,
request_path: Option<&str>,
query: Option<&str>,
) -> String {
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
let trimmed = trimmed.trim_end_matches('/');
let suffix = openai_image_path_suffix(request_path);
let mut url = if openai_image_base_includes_operation_path(trimmed) {
trimmed.to_string()
} else {
format!("{trimmed}{suffix}")
};
append_merged_query(&mut url, base_query, None, query, &[]);
url
}
fn openai_image_path_suffix(request_path: Option<&str>) -> &'static str {
match request_path
.map(str::trim)
.map(|value| value.trim_end_matches('/'))
{
Some("/v1/images/edits") | Some("/images/edits") => "/images/edits",
_ => "/images/generations",
}
}
fn openai_image_base_includes_operation_path(base_url: &str) -> bool {
let path = Url::parse(base_url)
.ok()
.map(|url| url.path().trim_end_matches('/').to_string())
.unwrap_or_else(|| base_url.trim_end_matches('/').to_string());
path.ends_with("/images/generations") || path.ends_with("/images/edits")
}
pub fn build_claude_messages_url(upstream_base_url: &str, query: Option<&str>) -> String {
build_claude_messages_operation_url(upstream_base_url, "", query)
}
pub(crate) fn build_claude_count_tokens_url(
upstream_base_url: &str,
query: Option<&str>,
) -> String {
build_claude_messages_operation_url(upstream_base_url, "/count_tokens", query)
}
fn build_claude_messages_operation_url(
upstream_base_url: &str,
operation_suffix: &str,
query: Option<&str>,
) -> String {
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
let trimmed = trimmed.trim_end_matches('/');
let parsed_url = Url::parse(trimmed).ok();
let parsed_path = parsed_url
.as_ref()
.map(|url| url.path().trim_matches('/'))
.unwrap_or_default();
let base_includes_count_tokens =
parsed_path == "messages/count_tokens" || parsed_path.ends_with("/messages/count_tokens");
let messages_base = if base_includes_count_tokens {
trimmed
.strip_suffix("/count_tokens")
.unwrap_or(trimmed)
.to_string()
} else if parsed_path.is_empty()
&& parsed_url
.as_ref()
.and_then(Url::host_str)
.is_some_and(|host| host.eq_ignore_ascii_case("api.anthropic.com"))
{
format!("{trimmed}/v1/messages")
} else if parsed_path.is_empty() {
format!("{trimmed}/messages")
} else if parsed_path.rsplit('/').next() == Some("messages") {
trimmed.to_string()
} else {
format!("{trimmed}/messages")
};
let mut url = if base_includes_count_tokens && operation_suffix == "/count_tokens" {
trimmed.to_string()
} else {
format!("{messages_base}{operation_suffix}")
};
append_merged_query(
&mut url,
base_query,
None,
query,
GATEWAY_CREDENTIAL_QUERY_KEYS,
);
url
}
pub fn build_gemini_content_url(
upstream_base_url: &str,
model: &str,
stream: bool,
query: Option<&str>,
) -> Option<String> {
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
let trimmed_model = model.trim();
if trimmed_base_url.is_empty() || trimmed_model.is_empty() {
return None;
}
let encoded_model = encode_url_path_segment(trimmed_model);
let operation = if stream {
"streamGenerateContent"
} else {
"generateContent"
};
let mut url = if trimmed_base_url.ends_with("/v1") || trimmed_base_url.ends_with("/v1beta") {
format!("{trimmed_base_url}/models/{encoded_model}:{operation}")
} else if gemini_content_base_url_contains_model_path(trimmed_base_url) {
let trimmed_base_url = strip_gemini_content_action(trimmed_base_url);
format!("{trimmed_base_url}:{operation}")
} else {
format!("{trimmed_base_url}/v1beta/models/{encoded_model}:{operation}")
};
append_merged_query(&mut url, base_query, None, query, &["key"]);
Some(url)
}
pub fn normalize_gemini_content_action_path(path: &str, stream: bool) -> String {
let trimmed = path.trim();
let (path, query) = split_path_query(trimmed);
let action = if stream {
"streamGenerateContent"
} else {
"generateContent"
};
let normalized = strip_gemini_content_action(path);
let normalized = if normalized.len() == path.len() {
path.to_string()
} else {
format!("{normalized}:{action}")
};
match query {
Some(query) => format!("{normalized}?{query}"),
None => normalized,
}
}
fn strip_gemini_content_action(value: &str) -> &str {
value
.strip_suffix(":streamGenerateContent")
.or_else(|| value.strip_suffix(":generateContent"))
.unwrap_or(value)
}
fn gemini_content_base_url_contains_model_path(value: &str) -> bool {
value.contains("/v1/models/") || value.contains("/v1beta/models/")
}
pub fn build_gemini_video_predict_long_running_url(
upstream_base_url: &str,
model: &str,
query: Option<&str>,
) -> Option<String> {
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
let trimmed_model = model.trim();
if trimmed_base_url.is_empty() || trimmed_model.is_empty() {
return None;
}
let encoded_model = encode_url_path_segment(trimmed_model);
let mut url = if trimmed_base_url.ends_with("/v1") || trimmed_base_url.ends_with("/v1beta") {
format!("{trimmed_base_url}/models/{encoded_model}:predictLongRunning")
} else if gemini_content_base_url_contains_model_path(trimmed_base_url) {
format!("{trimmed_base_url}:predictLongRunning")
} else {
format!("{trimmed_base_url}/v1beta/models/{encoded_model}:predictLongRunning")
};
append_merged_query(&mut url, base_query, None, query, &["key"]);
Some(url)
}
pub fn build_passthrough_path_url(
upstream_base_url: &str,
path: &str,
query: Option<&str>,
blocked_keys: &[&str],
) -> Option<String> {
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
let trimmed_path = path.trim();
if trimmed_base_url.is_empty() || trimmed_path.is_empty() {
return None;
}
let (trimmed_path, path_query) = split_path_query(trimmed_path);
let normalized_base_url =
if trimmed_base_url.ends_with("/v1beta") && trimmed_path.starts_with("/v1beta") {
trimmed_base_url.trim_end_matches("/v1beta")
} else if trimmed_base_url.ends_with("/v1") && trimmed_path.starts_with("/v1/") {
trimmed_base_url.trim_end_matches("/v1")
} else {
trimmed_base_url
};
let mut url = format!("{normalized_base_url}{trimmed_path}");
append_merged_query(&mut url, base_query, path_query, query, blocked_keys);
Some(url)
}
pub fn build_bigmodel_coding_models_url(upstream_base_url: &str) -> Option<String> {
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
if trimmed_base_url.is_empty() || !bigmodel_coding_models_base_is_supported(trimmed_base_url) {
return None;
}
let path = Url::parse(trimmed_base_url)
.ok()
.map(|url| url.path().trim_end_matches('/').to_string())
.unwrap_or_else(|| trimmed_base_url.trim_end_matches('/').to_string());
let mut url = if path.ends_with("/models") {
trimmed_base_url.to_string()
} else {
format!("{trimmed_base_url}/models")
};
append_merged_query(&mut url, base_query, None, None, &[]);
Some(url)
}
pub fn build_openai_compatible_models_url(upstream_base_url: &str) -> Option<String> {
if let Some(url) = build_bigmodel_coding_models_url(upstream_base_url) {
return Some(url);
}
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
if trimmed_base_url.is_empty() {
return None;
}
let mut url = if trimmed_base_url.ends_with("/models") {
trimmed_base_url.to_string()
} else {
format!("{trimmed_base_url}/models")
};
append_merged_query(&mut url, base_query, None, None, &[]);
Some(url)
}
pub fn build_gemini_files_passthrough_url(
upstream_base_url: &str,
path: &str,
query: Option<&str>,
) -> Option<String> {
let (trimmed_base_url, base_query) = split_base_url_query(upstream_base_url);
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
let trimmed_path = path.trim();
if trimmed_base_url.is_empty() || trimmed_path.is_empty() {
return None;
}
let (trimmed_path, path_query) = split_path_query(trimmed_path);
let normalized_base_url = if trimmed_base_url.ends_with("/v1beta")
&& (trimmed_path.starts_with("/v1beta/") || trimmed_path.starts_with("/upload/v1beta/"))
{
trimmed_base_url.trim_end_matches("/v1beta")
} else {
trimmed_base_url
};
let mut url = format!("{normalized_base_url}{trimmed_path}");
append_merged_query(&mut url, base_query, path_query, query, &["key"]);
Some(url)
}
fn split_base_url_query(base_url: &str) -> (&str, Option<&str>) {
let trimmed = base_url.trim();
trimmed
.split_once('?')
.map(|(base, query)| (base, Some(query)))
.unwrap_or((trimmed, None))
}
pub(crate) fn google_openai_compat_base_includes_api_root(base_url: &str) -> bool {
let Ok(parsed) = Url::parse(base_url.trim()) else {
return false;
};
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
return false;
};
let path = parsed.path().trim_end_matches('/');
if host == "generativelanguage.googleapis.com" {
return path == "/v1beta/openai" || path == "/v1/openai";
}
if looks_like_vertex_ai_host(&host) {
return path.ends_with("/endpoints/openapi");
}
false
}
pub fn openai_compatible_base_includes_api_root(base_url: &str) -> bool {
let trimmed = base_url.trim().trim_end_matches('/');
trimmed.ends_with("/v1")
|| google_openai_compat_base_includes_api_root(trimmed)
|| bigmodel_coding_base_includes_api_root(trimmed)
|| openai_compatible_base_includes_unversioned_api_root(trimmed)
}
pub fn v1_compatible_base_includes_api_root(base_url: &str) -> bool {
let trimmed = base_url.trim().trim_end_matches('/');
trimmed.ends_with("/v1") || openai_compatible_base_includes_unversioned_api_root(trimmed)
}
pub fn openai_compatible_base_includes_unversioned_api_root(base_url: &str) -> bool {
let trimmed = base_url.trim().trim_end_matches('/');
let path = Url::parse(trimmed)
.ok()
.map(|url| url.path().trim_end_matches('/').to_ascii_lowercase())
.unwrap_or_else(|| {
trimmed
.split_once('/')
.map(|(_, path)| format!("/{path}"))
.unwrap_or_default()
.trim_end_matches('/')
.to_ascii_lowercase()
});
!path.is_empty()
}
fn bigmodel_coding_base_includes_api_root(base_url: &str) -> bool {
let Ok(parsed) = Url::parse(base_url.trim()) else {
return false;
};
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
return false;
};
host == "open.bigmodel.cn" && parsed.path().trim_end_matches('/') == "/api/coding/paas/v4"
}
fn bigmodel_coding_models_base_is_supported(base_url: &str) -> bool {
let Ok(parsed) = Url::parse(base_url.trim()) else {
return false;
};
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
return false;
};
if host != "open.bigmodel.cn" {
return false;
}
matches!(
parsed.path().trim_end_matches('/'),
"/api/coding/paas/v4" | "/api/coding/paas/v4/models"
)
}
fn looks_like_vertex_ai_host(host: &str) -> bool {
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
let host = host.trim().to_ascii_lowercase();
host == VERTEX_AI_HOST
|| host
.strip_suffix(&format!("-{VERTEX_AI_HOST}"))
.is_some_and(is_vertex_region_label)
}
fn is_vertex_region_label(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 63
&& value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.as_bytes()
.last()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn split_path_query(path: &str) -> (&str, Option<&str>) {
path.split_once('?')
.map(|(path, query)| (path, Some(query)))
.unwrap_or((path, None))
}
fn append_merged_query(
url: &mut String,
base_query: Option<&str>,
path_query: Option<&str>,
request_query: Option<&str>,
blocked_request_keys: &[&str],
) {
let Some(query) =
merge_query_layers(base_query, path_query, request_query, blocked_request_keys)
else {
return;
};
if url.contains('?') {
url.push('&');
} else {
url.push('?');
}
url.push_str(&query);
}
fn merge_query_layers(
base_query: Option<&str>,
path_query: Option<&str>,
request_query: Option<&str>,
blocked_request_keys: &[&str],
) -> Option<String> {
if blocked_request_keys.is_empty()
&& path_query.is_none()
&& base_query.is_none()
&& request_query
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return request_query
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
}
let mut merged = BTreeMap::new();
merge_query_string(&mut merged, base_query, &[]);
merge_query_string(&mut merged, path_query, &[]);
merge_query_string(&mut merged, request_query, blocked_request_keys);
if merged.is_empty() {
return None;
}
let mut serializer = form_urlencoded::Serializer::new(String::new());
for (key, value) in merged {
serializer.append_pair(&key, &value);
}
Some(serializer.finish())
}
fn merge_query_string(
out: &mut BTreeMap<String, String>,
query: Option<&str>,
blocked_keys: &[&str],
) {
let Some(query) = query.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if blocked_keys
.iter()
.any(|blocked| key.as_ref().eq_ignore_ascii_case(blocked))
{
continue;
}
out.insert(key.into_owned(), value.into_owned());
}
}
#[cfg(test)]
mod tests {
use super::{
build_bigmodel_coding_models_url, build_claude_count_tokens_url, build_claude_messages_url,
build_gemini_content_url, build_gemini_files_passthrough_url,
build_gemini_video_predict_long_running_url, build_openai_chat_url,
build_openai_compatible_models_url, build_openai_image_url, build_openai_responses_url,
build_openai_search_url, build_passthrough_path_url, encode_url_path_segment,
normalize_gemini_content_action_path,
};
#[test]
fn merges_base_url_query_for_same_format_urls() {
assert_eq!(
build_openai_chat_url(
"https://api.openai.example/v1?tenant=demo",
Some("mode=fast&tenant=override")
),
"https://api.openai.example/v1/chat/completions?mode=fast&tenant=override"
);
}
#[test]
fn openai_chat_url_preserves_google_openai_compat_roots() {
assert_eq!(
build_openai_chat_url(
"https://generativelanguage.googleapis.com/v1beta/openai",
Some("trace=1")
),
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions?trace=1"
);
assert_eq!(
build_openai_chat_url(
"https://aiplatform.googleapis.com/v1/projects/project-1/locations/global/endpoints/openapi",
None,
),
"https://aiplatform.googleapis.com/v1/projects/project-1/locations/global/endpoints/openapi/chat/completions"
);
}
#[test]
fn openai_urls_preserve_bigmodel_coding_api_root() {
assert_eq!(
build_openai_chat_url(
"https://open.bigmodel.cn/api/coding/paas/v4",
Some("trace=1")
),
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions?trace=1"
);
assert_eq!(
build_openai_responses_url("https://open.bigmodel.cn/api/coding/paas/v4", None, false),
"https://open.bigmodel.cn/api/coding/paas/v4/responses"
);
}
#[test]
fn openai_urls_preserve_unversioned_api_root() {
assert_eq!(
build_openai_chat_url("https://proxy.example.com/api", Some("trace=1")),
"https://proxy.example.com/api/chat/completions?trace=1"
);
assert_eq!(
build_openai_chat_url("https://proxy.example.com/openai", None),
"https://proxy.example.com/openai/chat/completions"
);
assert_eq!(
build_openai_chat_url("https://proxy.example.com", None),
"https://proxy.example.com/chat/completions"
);
assert_eq!(
build_openai_chat_url("https://api.deepseek.com", None),
"https://api.deepseek.com/chat/completions"
);
assert_eq!(
build_openai_responses_url("https://proxy.example.com/api", None, false),
"https://proxy.example.com/api/responses"
);
assert_eq!(
build_openai_responses_url("https://api.deepseek.com", None, false),
"https://api.deepseek.com/responses"
);
assert_eq!(
build_openai_image_url(
"https://proxy.example.com/api",
Some("/v1/images/generations"),
None
),
"https://proxy.example.com/api/images/generations"
);
}
#[test]
fn claude_messages_url_preserves_v1_and_unversioned_api_roots() {
assert_eq!(
build_claude_messages_url("https://api.anthropic.example/v1", Some("trace=1")),
"https://api.anthropic.example/v1/messages?trace=1"
);
assert_eq!(
build_claude_messages_url("https://proxy.example.com/api", None),
"https://proxy.example.com/api/messages"
);
assert_eq!(
build_claude_messages_url("https://proxy.example.com/anthropic", None),
"https://proxy.example.com/anthropic/messages"
);
assert_eq!(
build_claude_messages_url("https://api.anthropic.example", None),
"https://api.anthropic.example/messages"
);
assert_eq!(
build_claude_messages_url("https://api.anthropic.com", None),
"https://api.anthropic.com/v1/messages"
);
assert_eq!(
build_claude_messages_url(
"https://proxy.example.com/anthropic?key=base-secret&tenant=base",
Some("KEY=request-secret&trace=1")
),
"https://proxy.example.com/anthropic/messages?key=base-secret&tenant=base&trace=1"
);
}
#[test]
fn claude_count_tokens_url_preserves_configured_query_and_strips_request_credentials() {
assert_eq!(
build_claude_count_tokens_url(
"https://proxy.example.com/anthropic?key=base-secret&tenant=base",
Some("key=request-secret&trace=1")
),
"https://proxy.example.com/anthropic/messages/count_tokens?key=base-secret&tenant=base&trace=1"
);
assert_eq!(
build_claude_count_tokens_url("https://api.anthropic.com", None),
"https://api.anthropic.com/v1/messages/count_tokens"
);
assert_eq!(
build_claude_count_tokens_url("https://api.anthropic.example", None),
"https://api.anthropic.example/messages/count_tokens"
);
assert_eq!(
build_claude_count_tokens_url(
"https://proxy.example.com/anthropic/messages",
Some("trace=1")
),
"https://proxy.example.com/anthropic/messages/count_tokens?trace=1"
);
assert_eq!(
build_claude_count_tokens_url(
"https://proxy.example.com/v1/messages/count_tokens?key=base-secret",
Some("key=request-secret&trace=1")
),
"https://proxy.example.com/v1/messages/count_tokens?key=base-secret&trace=1"
);
assert_eq!(
build_claude_messages_url("https://proxy.example.com/v1/messages/count_tokens", None),
"https://proxy.example.com/v1/messages"
);
}
#[test]
fn bigmodel_coding_models_url_uses_models_resource() {
assert_eq!(
build_bigmodel_coding_models_url(
"https://open.bigmodel.cn/api/coding/paas/v4?tenant=demo"
)
.as_deref(),
Some("https://open.bigmodel.cn/api/coding/paas/v4/models?tenant=demo")
);
assert_eq!(
build_bigmodel_coding_models_url("https://open.bigmodel.cn/api/coding/paas/v4/models")
.as_deref(),
Some("https://open.bigmodel.cn/api/coding/paas/v4/models")
);
}
#[test]
fn openai_compatible_models_url_preserves_unversioned_api_root() {
assert_eq!(
build_openai_compatible_models_url("https://proxy.example.com/api?tenant=demo")
.as_deref(),
Some("https://proxy.example.com/api/models?tenant=demo")
);
assert_eq!(
build_openai_compatible_models_url("https://proxy.example.com/openai").as_deref(),
Some("https://proxy.example.com/openai/models")
);
assert_eq!(
build_openai_compatible_models_url("https://proxy.example.com").as_deref(),
Some("https://proxy.example.com/models")
);
}
#[test]
fn openai_responses_url_preserves_codex_path_prefix() {
assert_eq!(
build_openai_responses_url("https://tiger.bookapi.cc/codex", None, false),
"https://tiger.bookapi.cc/codex/responses"
);
assert_eq!(
build_openai_responses_url("https://tiger.bookapi.cc/codex?tenant=demo", None, true),
"https://tiger.bookapi.cc/codex/responses/compact?tenant=demo"
);
}
#[test]
fn openai_search_url_preserves_api_and_codex_roots() {
assert_eq!(
build_openai_search_url(
"https://api.openai.com/v1?tenant=base",
Some("trace=1&tenant=request")
),
"https://api.openai.com/v1/alpha/search?tenant=request&trace=1"
);
assert_eq!(
build_openai_search_url("https://chatgpt.com/backend-api/codex/", None),
"https://chatgpt.com/backend-api/codex/alpha/search"
);
}
#[test]
fn openai_image_url_uses_images_surface() {
assert_eq!(
build_openai_image_url(
"https://api.openai.example/v1?tenant=demo",
Some("/v1/images/generations"),
Some("trace=1")
),
"https://api.openai.example/v1/images/generations?tenant=demo&trace=1"
);
assert_eq!(
build_openai_image_url(
"https://api.openai.example/v1",
Some("/v1/images/edits"),
None
),
"https://api.openai.example/v1/images/edits"
);
}
#[test]
fn merges_base_url_query_for_dynamic_gemini_content_urls() {
assert_eq!(
build_gemini_content_url(
"https://generativelanguage.googleapis.com/v1beta?alt=sse",
"gemini-2.5-pro",
true,
Some("foo=bar&key=secret")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse&foo=bar"
)
);
}
#[test]
fn gemini_content_urls_rewrite_existing_base_action_for_stream_mode() {
assert_eq!(
build_gemini_content_url(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
"ignored-model",
true,
Some("foo=bar")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?foo=bar"
)
);
assert_eq!(
build_gemini_content_url(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
"ignored-model",
false,
Some("foo=bar")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?foo=bar"
)
);
assert_eq!(
build_gemini_content_url(
"https://generativelanguage.googleapis.com/v1/models/gemini-2.5-pro:generateContent",
"ignored-model",
true,
Some("foo=bar")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1/models/gemini-2.5-pro:streamGenerateContent?foo=bar"
)
);
}
#[test]
fn normalizes_gemini_content_action_in_custom_paths() {
assert_eq!(
normalize_gemini_content_action_path(
"/v1beta/models/gemini-2.5-pro:generateContent?alt=sse",
true
),
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
);
assert_eq!(
normalize_gemini_content_action_path(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
false
),
"/v1beta/models/gemini-2.5-pro:generateContent"
);
}
#[test]
fn merges_base_path_and_request_query_for_passthrough_paths() {
assert_eq!(
build_passthrough_path_url(
"https://api.openai.example/v1?tenant=demo",
"/videos/generations?variant=video",
Some("size=1024"),
&[]
)
.as_deref(),
Some(
"https://api.openai.example/v1/videos/generations?size=1024&tenant=demo&variant=video"
)
);
}
#[test]
fn passthrough_path_does_not_duplicate_openai_v1_root() {
assert_eq!(
build_passthrough_path_url(
"https://api.openai.example/v1?tenant=demo",
"/v1/chat/completions?variant=chat",
Some("trace=1"),
&[]
)
.as_deref(),
Some("https://api.openai.example/v1/chat/completions?tenant=demo&trace=1&variant=chat")
);
}
#[test]
fn merges_base_url_query_for_gemini_files_passthrough_urls() {
assert_eq!(
build_gemini_files_passthrough_url(
"https://generativelanguage.googleapis.com/v1beta?alt=media",
"/upload/v1beta/files?uploadType=resumable",
Some("key=secret&pageSize=10")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/upload/v1beta/files?alt=media&pageSize=10&uploadType=resumable"
)
);
}
#[test]
fn merges_base_url_query_for_gemini_video_urls() {
assert_eq!(
build_gemini_video_predict_long_running_url(
"https://generativelanguage.googleapis.com/v1beta?alt=sse",
"veo-3.0-generate-preview",
Some("foo=bar&key=secret")
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning?alt=sse&foo=bar"
)
);
}
#[test]
fn gemini_model_names_cannot_inject_path_query_or_fragment_components() {
let model = "model/../../admin?key=attacker#fragment";
assert_eq!(
build_gemini_content_url(
"https://generativelanguage.googleapis.com/v1beta",
model,
false,
None,
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/model%2F..%2F..%2Fadmin%3Fkey=attacker%23fragment:generateContent"
)
);
assert_eq!(
build_gemini_video_predict_long_running_url(
"https://generativelanguage.googleapis.com/v1beta",
model,
None,
)
.as_deref(),
Some(
"https://generativelanguage.googleapis.com/v1beta/models/model%2F..%2F..%2Fadmin%3Fkey=attacker%23fragment:predictLongRunning"
)
);
}
#[test]
fn dynamic_path_segment_encoding_keeps_raw_values_in_one_segment() {
assert_eq!(
encode_url_path_segment("gemini+2.5@preview~/model%2Fraw"),
"gemini+2.5@preview~%2Fmodel%252Fraw"
);
assert_eq!(encode_url_path_segment(".."), "..");
}
}