mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-15 07:30:21 +08:00
fix(provider): support unversioned API roots in model fetch
This commit is contained in:
@@ -3,6 +3,10 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
};
|
||||
use aether_provider_transport::url::{
|
||||
build_bigmodel_coding_models_url, build_openai_compatible_models_url,
|
||||
openai_compatible_base_includes_unversioned_api_root,
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -600,24 +604,17 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
}
|
||||
|
||||
fn build_v1_models_url(base_url: &str) -> Option<String> {
|
||||
let (trimmed_base_url, query) = split_url_query(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("/v1") {
|
||||
format!("{trimmed_base_url}/models")
|
||||
} else {
|
||||
format!("{trimmed_base_url}/v1/models")
|
||||
};
|
||||
if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
|
||||
url.push('?');
|
||||
url.push_str(query);
|
||||
}
|
||||
Some(url)
|
||||
build_openai_compatible_models_url(base_url)
|
||||
}
|
||||
|
||||
fn build_codex_models_url(base_url: &str) -> Option<String> {
|
||||
if let Some(url) = build_bigmodel_coding_models_url(base_url) {
|
||||
return Some(url);
|
||||
}
|
||||
if openai_compatible_base_includes_unversioned_api_root(base_url) {
|
||||
return build_openai_compatible_models_url(base_url);
|
||||
}
|
||||
|
||||
let (trimmed_base_url, query) = split_url_query(base_url);
|
||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||
if trimmed_base_url.is_empty() {
|
||||
@@ -965,6 +962,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_models_fetch_url_supports_bigmodel_coding_paas_root() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url(
|
||||
"openai",
|
||||
"openai:chat",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
),
|
||||
Some((
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models".to_string(),
|
||||
"openai:chat".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
build_models_fetch_url(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
),
|
||||
Some((
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models".to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_models_fetch_url_preserves_unversioned_api_root() {
|
||||
assert_eq!(
|
||||
build_models_fetch_url("openai", "openai:chat", "https://proxy.example.com/api"),
|
||||
Some((
|
||||
"https://proxy.example.com/api/models".to_string(),
|
||||
"openai:chat".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
build_models_fetch_url("codex", "openai:responses", "https://proxy.example.com/api"),
|
||||
Some((
|
||||
"https://proxy.example.com/api/models".to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_normalizes_openai_payload() {
|
||||
let parsed = parse_models_response(
|
||||
|
||||
@@ -17,8 +17,8 @@ use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::logic::{
|
||||
extract_error_message, parse_models_response_page, parse_windsurf_model_configs_response,
|
||||
preset_models_for_provider,
|
||||
aggregate_models_for_cache, extract_error_message, parse_models_response_page,
|
||||
parse_windsurf_model_configs_response, preset_models_for_provider,
|
||||
};
|
||||
use crate::transport::{
|
||||
build_antigravity_fetch_available_models_plan, build_gemini_cli_load_code_assist_plan,
|
||||
@@ -204,7 +204,8 @@ async fn fetch_standard_models(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_success_outcome(all_models, None, has_success).with_errors(errors))
|
||||
let merged_models = aggregate_models_for_cache(&all_models);
|
||||
Ok(build_success_outcome(merged_models, None, has_success).with_errors(errors))
|
||||
}
|
||||
|
||||
async fn fetch_standard_models_for_transport(
|
||||
@@ -433,7 +434,7 @@ async fn fetch_vertex_api_key_models(
|
||||
|
||||
for base_url in iter_vertex_base_urls(transports) {
|
||||
let url = build_vertex_google_list_url(&base_url, api_key, None);
|
||||
let outcome = fetch_vertex_models_from_url(
|
||||
let outcome = match fetch_vertex_models_from_url(
|
||||
runtime,
|
||||
reference_transport,
|
||||
&url,
|
||||
@@ -442,7 +443,14 @@ async fn fetch_vertex_api_key_models(
|
||||
"gemini:generate_content",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
hard_errors.push(format!("{base_url}: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
has_success |= outcome.has_success;
|
||||
if let Some(error) = outcome.error {
|
||||
if is_soft_not_found(&error) {
|
||||
@@ -507,7 +515,7 @@ async fn fetch_vertex_service_account_models(
|
||||
("anthropic", claude_transport, "claude:messages"),
|
||||
] {
|
||||
let url = build_vertex_service_account_list_url(&base, publisher, None);
|
||||
let outcome = fetch_vertex_models_from_url(
|
||||
let outcome = match fetch_vertex_models_from_url(
|
||||
runtime,
|
||||
transport,
|
||||
&url,
|
||||
@@ -516,7 +524,14 @@ async fn fetch_vertex_service_account_models(
|
||||
api_format,
|
||||
Some(("authorization".to_string(), format!("Bearer {token}"))),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
hard_errors.push(format!("{url}: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
has_success |= outcome.has_success;
|
||||
if let Some(error) = outcome.error {
|
||||
let labeled = format!("{url}: {error}");
|
||||
@@ -1304,6 +1319,11 @@ mod tests {
|
||||
status_code: u16,
|
||||
}
|
||||
|
||||
struct RoutingTestRuntime {
|
||||
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||
routes: Vec<(String, Result<(u16, Value), String>)>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelFetchTransportRuntime for TestRuntime {
|
||||
async fn resolve_local_oauth_request_auth(
|
||||
@@ -1344,6 +1364,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelFetchTransportRuntime for RoutingTestRuntime {
|
||||
async fn resolve_local_oauth_request_auth(
|
||||
&self,
|
||||
_transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Result<Option<aether_provider_transport::LocalResolvedOAuthRequestAuth>, String>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_model_fetch_proxy(
|
||||
&self,
|
||||
_transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<aether_contracts::ProxySnapshot> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn execute_model_fetch_execution_plan(
|
||||
&self,
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
) -> Result<ExecutionResult, String> {
|
||||
self.executed_urls
|
||||
.lock()
|
||||
.expect("executed_urls lock")
|
||||
.push(plan.url.clone());
|
||||
let Some((_, route_result)) = self
|
||||
.routes
|
||||
.iter()
|
||||
.find(|(url_part, _)| plan.url.contains(url_part))
|
||||
else {
|
||||
return Err(format!("unexpected models fetch URL {}", plan.url));
|
||||
};
|
||||
let (status_code, response_body) = match route_result {
|
||||
Ok((status_code, response_body)) => (*status_code, response_body.clone()),
|
||||
Err(err) => return Err(err.clone()),
|
||||
};
|
||||
Ok(ExecutionResult {
|
||||
request_id: plan.request_id.clone(),
|
||||
candidate_id: plan.candidate_id.clone(),
|
||||
status_code,
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(ResponseBody {
|
||||
json_body: Some(response_body),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_custom_aiplatform_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
@@ -1455,6 +1526,27 @@ mod tests {
|
||||
transport
|
||||
}
|
||||
|
||||
fn sample_openai_transport(
|
||||
endpoint_id: &str,
|
||||
api_format: &str,
|
||||
base_url: &str,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
let mut transport = sample_custom_aiplatform_transport();
|
||||
transport.provider.provider_type = "custom".to_string();
|
||||
transport.provider.name = "OpenAI Compat".to_string();
|
||||
transport.endpoint.id = endpoint_id.to_string();
|
||||
transport.endpoint.api_format = api_format.to_string();
|
||||
transport.endpoint.api_family = Some("openai".to_string());
|
||||
transport.endpoint.endpoint_kind = api_format
|
||||
.split_once(':')
|
||||
.map(|(_, endpoint_kind)| endpoint_kind.to_string());
|
||||
transport.endpoint.base_url = base_url.to_string();
|
||||
transport.endpoint.custom_path = None;
|
||||
transport.key.api_formats = Some(vec![api_format.to_string()]);
|
||||
transport.key.decrypted_api_key = "openai-secret".to_string();
|
||||
transport
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_selection_keeps_codex_on_standard_transport_fetch() {
|
||||
let strategy = select_model_fetch_strategy(&[sample_codex_transport()])
|
||||
@@ -1525,6 +1617,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_transport_merges_successful_endpoint_models_when_one_endpoint_fails() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = RoutingTestRuntime {
|
||||
executed_urls: Arc::clone(&executed_urls),
|
||||
routes: vec![
|
||||
(
|
||||
"https://bad.example.com/v1/models".to_string(),
|
||||
Err("connection reset".to_string()),
|
||||
),
|
||||
(
|
||||
"https://chat.example.com/v1/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
"data": [{ "id": "shared-model" }]
|
||||
}),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"https://responses.example.com/v1/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
"data": [
|
||||
{ "id": "shared-model" },
|
||||
{ "id": "responses-only" }
|
||||
]
|
||||
}),
|
||||
)),
|
||||
),
|
||||
],
|
||||
};
|
||||
let transports = vec![
|
||||
sample_openai_transport("endpoint-bad", "openai:chat", "https://bad.example.com"),
|
||||
sample_openai_transport("endpoint-chat", "openai:chat", "https://chat.example.com"),
|
||||
sample_openai_transport(
|
||||
"endpoint-responses",
|
||||
"openai:responses",
|
||||
"https://responses.example.com",
|
||||
),
|
||||
];
|
||||
|
||||
let outcome = fetch_models_from_transports(&runtime, &transports)
|
||||
.await
|
||||
.expect("models fetch should keep successful endpoint results");
|
||||
|
||||
assert!(outcome.has_success);
|
||||
assert_eq!(
|
||||
outcome.fetched_model_ids,
|
||||
vec!["responses-only", "shared-model"]
|
||||
);
|
||||
assert_eq!(outcome.cached_models.len(), 2);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(outcome.errors[0].contains("connection reset"));
|
||||
let shared_model = outcome
|
||||
.cached_models
|
||||
.iter()
|
||||
.find(|model| model.get("id").and_then(Value::as_str) == Some("shared-model"))
|
||||
.expect("shared model should be cached once");
|
||||
assert_eq!(
|
||||
shared_model.get("api_formats"),
|
||||
Some(&json!(["openai:chat", "openai:responses"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vertex_models_fetch_continues_when_one_base_url_errors() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = RoutingTestRuntime {
|
||||
executed_urls: Arc::clone(&executed_urls),
|
||||
routes: vec![
|
||||
(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/publishers/google/models"
|
||||
.to_string(),
|
||||
Err("connect timeout".to_string()),
|
||||
),
|
||||
(
|
||||
"https://aiplatform.googleapis.com/v1beta1/publishers/google/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
"models": [{
|
||||
"name": "publishers/google/models/gemini-3.1-pro-preview"
|
||||
}]
|
||||
}),
|
||||
)),
|
||||
),
|
||||
],
|
||||
};
|
||||
let mut failing_transport = sample_custom_aiplatform_transport();
|
||||
failing_transport.endpoint.base_url =
|
||||
"https://us-central1-aiplatform.googleapis.com".to_string();
|
||||
let mut successful_transport = sample_custom_aiplatform_transport();
|
||||
successful_transport.endpoint.id = "endpoint-2".to_string();
|
||||
successful_transport.endpoint.base_url = "https://aiplatform.googleapis.com".to_string();
|
||||
|
||||
let outcome =
|
||||
fetch_models_from_transports(&runtime, &[failing_transport, successful_transport])
|
||||
.await
|
||||
.expect("vertex models fetch should keep successful base URL results");
|
||||
|
||||
assert!(outcome.has_success);
|
||||
assert_eq!(outcome.fetched_model_ids, vec!["gemini-3.1-pro-preview"]);
|
||||
assert_eq!(outcome.cached_models.len(), 1);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(outcome.errors[0].contains("connect timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_model_fetch_uses_model_garden_list_endpoint() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -807,6 +807,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_bigmodel_coding_models_fetch_plan() {
|
||||
let runtime = TestRuntime {
|
||||
oauth_auth: None,
|
||||
proxy: None,
|
||||
};
|
||||
let mut transport = sample_transport("openai", "openai:chat", "api_key");
|
||||
transport.endpoint.base_url = "https://open.bigmodel.cn/api/coding/paas/v4".to_string();
|
||||
transport.key.decrypted_auth_config = None;
|
||||
let plan = build_models_fetch_execution_plan(&runtime, &transport)
|
||||
.await
|
||||
.expect("plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.url,
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_unversioned_api_root_models_fetch_plan() {
|
||||
let runtime = TestRuntime {
|
||||
oauth_auth: None,
|
||||
proxy: None,
|
||||
};
|
||||
let mut transport = sample_transport("openai", "openai:chat", "api_key");
|
||||
transport.endpoint.base_url = "https://proxy.example.com/api".to_string();
|
||||
transport.key.decrypted_auth_config = None;
|
||||
let plan = build_models_fetch_execution_plan(&runtime, &transport)
|
||||
.await
|
||||
.expect("plan");
|
||||
|
||||
assert_eq!(plan.url, "https://proxy.example.com/api/models");
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_codex_models_fetch_plan_with_account_header() {
|
||||
let runtime = TestRuntime {
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::claude_code::build_claude_code_messages_url;
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_responses_url, build_passthrough_path_url,
|
||||
google_openai_compat_base_includes_api_root, normalize_gemini_content_action_path,
|
||||
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
||||
openai_compatible_base_includes_api_root,
|
||||
};
|
||||
use crate::vertex::{
|
||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
||||
@@ -403,7 +403,7 @@ fn build_provider_v1_url(
|
||||
.unwrap_or_else(|| upstream_base_url.trim())
|
||||
.trim_end_matches('/');
|
||||
let path = if base_without_query.ends_with("/v1")
|
||||
|| google_openai_compat_base_includes_api_root(base_without_query)
|
||||
|| openai_compatible_base_includes_api_root(base_without_query)
|
||||
{
|
||||
v1_path
|
||||
} else {
|
||||
|
||||
@@ -7,12 +7,11 @@ use url::Url;
|
||||
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") || google_openai_compat_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}/chat/completions")
|
||||
} else {
|
||||
format!("{trimmed}/v1/chat/completions")
|
||||
};
|
||||
let mut url = if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}/chat/completions")
|
||||
} else {
|
||||
format!("{trimmed}/v1/chat/completions")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
}
|
||||
@@ -31,7 +30,7 @@ pub fn build_openai_responses_url(
|
||||
};
|
||||
let mut url = if is_codex_cli_backend_url(trimmed)
|
||||
|| trimmed.ends_with("/codex")
|
||||
|| trimmed.ends_with("/v1")
|
||||
|| openai_compatible_base_includes_api_root(trimmed)
|
||||
{
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
@@ -51,7 +50,7 @@ pub fn build_openai_image_url(
|
||||
let suffix = openai_image_path_suffix(request_path);
|
||||
let mut url = if openai_image_base_includes_operation_path(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.ends_with("/v1") || google_openai_compat_base_includes_api_root(trimmed) {
|
||||
} else if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
format!("{trimmed}/v1{suffix}")
|
||||
@@ -200,6 +199,48 @@ pub fn build_passthrough_path_url(
|
||||
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/models") {
|
||||
trimmed_base_url.to_string()
|
||||
} else if path.ends_with("/models") {
|
||||
format!("{trimmed_base_url}/models")
|
||||
} else {
|
||||
format!("{trimmed_base_url}/models/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 openai_compatible_base_includes_api_root(trimmed_base_url) {
|
||||
format!("{trimmed_base_url}/models")
|
||||
} else {
|
||||
format!("{trimmed_base_url}/v1/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,
|
||||
@@ -254,6 +295,49 @@ pub(crate) fn google_openai_compat_base_includes_api_root(base_url: &str) -> boo
|
||||
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 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.to_ascii_lowercase());
|
||||
path.ends_with("/api")
|
||||
}
|
||||
|
||||
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" | "/api/coding/paas/v4/models/models"
|
||||
)
|
||||
}
|
||||
|
||||
fn looks_like_vertex_ai_host(host: &str) -> bool {
|
||||
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
|
||||
host == VERTEX_AI_HOST
|
||||
@@ -343,8 +427,9 @@ fn merge_query_string(
|
||||
#[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_image_url,
|
||||
build_bigmodel_coding_models_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_passthrough_path_url,
|
||||
normalize_gemini_content_action_path,
|
||||
};
|
||||
@@ -378,6 +463,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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_responses_url("https://proxy.example.com/api", None, false),
|
||||
"https://proxy.example.com/api/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 bigmodel_coding_models_url_uses_double_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/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/models")
|
||||
);
|
||||
assert_eq!(
|
||||
build_bigmodel_coding_models_url(
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models/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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_url_preserves_codex_path_prefix() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user