Files
Aether/crates/aether-provider-transport/src/url.rs

429 lines
14 KiB
Rust
Raw Normal View History

use std::collections::BTreeMap;
use super::provider_types::is_codex_cli_backend_url;
use url::form_urlencoded;
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 = if trimmed.ends_with("/v1") {
format!("{trimmed}/chat/completions")
} else {
format!("{trimmed}/v1/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 = if is_codex_cli_backend_url(trimmed)
|| trimmed.ends_with("/codex")
|| trimmed.ends_with("/v1")
{
format!("{trimmed}{suffix}")
} else {
format!("{trimmed}/v1{suffix}")
};
append_merged_query(&mut url, base_query, None, query, &[]);
url
}
pub fn build_claude_messages_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 = if trimmed.ends_with("/v1") {
format!("{trimmed}/messages")
} else {
format!("{trimmed}/v1/messages")
};
append_merged_query(&mut url, base_query, None, query, &[]);
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 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/{trimmed_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/{trimmed_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 mut url = if trimmed_base_url.ends_with("/v1") || trimmed_base_url.ends_with("/v1beta") {
format!("{trimmed_base_url}/models/{trimmed_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/{trimmed_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 {
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_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))
}
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_keys: &[&str],
) {
let Some(query) = merge_query_layers(base_query, path_query, request_query, blocked_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_keys: &[&str],
) -> Option<String> {
if blocked_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();
for source in [base_query, path_query, request_query] {
merge_query_string(&mut merged, source, blocked_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_gemini_content_url, build_gemini_files_passthrough_url,
build_gemini_video_predict_long_running_url, build_openai_chat_url,
build_openai_responses_url, build_passthrough_path_url,
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_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 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 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"
)
);
}
}