mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
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.
966 lines
32 KiB
Rust
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(".."), "..");
|
|
}
|
|
}
|