mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'zhefox/main'
This commit is contained in:
@@ -99,6 +99,7 @@ static PROVIDER_QUERY_POOL_LOAD_BALANCE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|||||||
struct ProviderQueryKeyFetchResult {
|
struct ProviderQueryKeyFetchResult {
|
||||||
models: Vec<Value>,
|
models: Vec<Value>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
warning: Option<String>,
|
||||||
from_cache: bool,
|
from_cache: bool,
|
||||||
has_success: bool,
|
has_success: bool,
|
||||||
}
|
}
|
||||||
@@ -288,6 +289,7 @@ fn provider_query_codex_preset_fallback(
|
|||||||
Some(ProviderQueryKeyFetchResult {
|
Some(ProviderQueryKeyFetchResult {
|
||||||
models: aggregate_models_for_cache(&models),
|
models: aggregate_models_for_cache(&models),
|
||||||
error: None,
|
error: None,
|
||||||
|
warning: None,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: true,
|
has_success: true,
|
||||||
})
|
})
|
||||||
@@ -427,6 +429,7 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models,
|
models,
|
||||||
error: None,
|
error: None,
|
||||||
|
warning: None,
|
||||||
from_cache: true,
|
from_cache: true,
|
||||||
has_success: true,
|
has_success: true,
|
||||||
});
|
});
|
||||||
@@ -444,6 +447,7 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models,
|
models,
|
||||||
error: None,
|
error: None,
|
||||||
|
warning: None,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: true,
|
has_success: true,
|
||||||
});
|
});
|
||||||
@@ -451,6 +455,7 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
error: Some(ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL.to_string()),
|
error: Some(ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL.to_string()),
|
||||||
|
warning: None,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: false,
|
has_success: false,
|
||||||
});
|
});
|
||||||
@@ -477,6 +482,7 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
error: Some(all_errors.join("; ")),
|
error: Some(all_errors.join("; ")),
|
||||||
|
warning: None,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: false,
|
has_success: false,
|
||||||
});
|
});
|
||||||
@@ -492,6 +498,7 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
error: Some(all_errors.join("; ")),
|
error: Some(all_errors.join("; ")),
|
||||||
|
warning: None,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: false,
|
has_success: false,
|
||||||
});
|
});
|
||||||
@@ -516,18 +523,25 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut error = if all_errors.is_empty() {
|
let has_models = !unique_models.is_empty();
|
||||||
None
|
let mut error = if !has_models && !all_errors.is_empty() {
|
||||||
} else {
|
|
||||||
Some(all_errors.join("; "))
|
Some(all_errors.join("; "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
if unique_models.is_empty() && error.is_none() {
|
let warning = if has_models && !all_errors.is_empty() {
|
||||||
|
Some(all_errors.join("; "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if !has_models && error.is_none() {
|
||||||
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL.to_string());
|
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ProviderQueryKeyFetchResult {
|
Ok(ProviderQueryKeyFetchResult {
|
||||||
models: provider_query_filter_models_for_key(provider, key, unique_models),
|
models: provider_query_filter_models_for_key(provider, key, unique_models),
|
||||||
error,
|
error,
|
||||||
|
warning,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
has_success: outcome.has_success,
|
has_success: outcome.has_success,
|
||||||
})
|
})
|
||||||
@@ -588,6 +602,7 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
"data": {
|
"data": {
|
||||||
"models": models,
|
"models": models,
|
||||||
"error": result.error,
|
"error": result.error,
|
||||||
|
"warning": result.warning,
|
||||||
"from_cache": result.from_cache,
|
"from_cache": result.from_cache,
|
||||||
},
|
},
|
||||||
"provider": provider_query_provider_payload(&provider),
|
"provider": provider_query_provider_payload(&provider),
|
||||||
@@ -620,6 +635,7 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
"data": {
|
"data": {
|
||||||
"models": models,
|
"models": models,
|
||||||
"error": serde_json::Value::Null,
|
"error": serde_json::Value::Null,
|
||||||
|
"warning": serde_json::Value::Null,
|
||||||
"from_cache": true,
|
"from_cache": true,
|
||||||
"keys_total": active_key_count,
|
"keys_total": active_key_count,
|
||||||
"keys_cached": active_key_count,
|
"keys_cached": active_key_count,
|
||||||
@@ -643,6 +659,7 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
|
|
||||||
let mut all_models = Vec::new();
|
let mut all_models = Vec::new();
|
||||||
let mut all_errors = Vec::new();
|
let mut all_errors = Vec::new();
|
||||||
|
let mut all_warnings = Vec::new();
|
||||||
let mut cache_hit_count = 0usize;
|
let mut cache_hit_count = 0usize;
|
||||||
let mut fetch_count = 0usize;
|
let mut fetch_count = 0usize;
|
||||||
for key in &ordered_keys {
|
for key in &ordered_keys {
|
||||||
@@ -657,6 +674,13 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
error
|
error
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(warning) = result.warning {
|
||||||
|
all_warnings.push(format!(
|
||||||
|
"Key {}: {}",
|
||||||
|
provider_query_key_display_name(key),
|
||||||
|
warning
|
||||||
|
));
|
||||||
|
}
|
||||||
if result.from_cache {
|
if result.from_cache {
|
||||||
cache_hit_count += 1;
|
cache_hit_count += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -682,10 +706,17 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
provider_query_write_provider_cached_models(state, &provider.id, &models).await;
|
provider_query_write_provider_cached_models(state, &provider.id, &models).await;
|
||||||
}
|
}
|
||||||
let success = !models.is_empty();
|
let success = !models.is_empty();
|
||||||
let mut error = if all_errors.is_empty() {
|
let mut all_issues = all_errors;
|
||||||
None
|
all_issues.extend(all_warnings);
|
||||||
|
let mut error = if !success && !all_issues.is_empty() {
|
||||||
|
Some(all_issues.join("; "))
|
||||||
} else {
|
} else {
|
||||||
Some(all_errors.join("; "))
|
None
|
||||||
|
};
|
||||||
|
let warning = if success && !all_issues.is_empty() {
|
||||||
|
Some(all_issues.join("; "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
if !success && error.is_none() {
|
if !success && error.is_none() {
|
||||||
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_KEY_DETAIL.to_string());
|
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_KEY_DETAIL.to_string());
|
||||||
@@ -697,6 +728,7 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
|||||||
"data": {
|
"data": {
|
||||||
"models": models,
|
"models": models,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"warning": warning,
|
||||||
"from_cache": fetch_count == 0 && cache_hit_count > 0,
|
"from_cache": fetch_count == 0 && cache_hit_count > 0,
|
||||||
"keys_total": active_key_count,
|
"keys_total": active_key_count,
|
||||||
"keys_cached": cache_hit_count,
|
"keys_cached": cache_hit_count,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ pub use config::{
|
|||||||
};
|
};
|
||||||
pub use logic::{
|
pub use logic::{
|
||||||
aggregate_models_for_cache, apply_model_filters, build_models_fetch_url,
|
aggregate_models_for_cache, apply_model_filters, build_models_fetch_url,
|
||||||
endpoint_supports_rust_models_fetch, extract_error_message, json_string_list,
|
deepseek_anthropic_models_fetch_uses_openai_auth, endpoint_supports_rust_models_fetch,
|
||||||
merge_upstream_metadata, parse_models_response, parse_models_response_page,
|
extract_error_message, json_string_list, merge_upstream_metadata, parse_models_response,
|
||||||
parse_windsurf_model_configs_response, preset_models_for_provider,
|
parse_models_response_page, parse_windsurf_model_configs_response, preset_models_for_provider,
|
||||||
provider_type_uses_preset_models, select_models_fetch_endpoint,
|
provider_type_uses_preset_models, select_models_fetch_endpoint,
|
||||||
selected_models_fetch_endpoints, ModelFetchRunSummary, ModelsFetchPage, ModelsFetchSuccess,
|
selected_models_fetch_endpoints, ModelFetchRunSummary, ModelsFetchPage, ModelsFetchSuccess,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
|
use aether_provider_transport::provider_types::is_codex_cli_backend_url;
|
||||||
|
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 regex::Regex;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -69,8 +74,10 @@ pub fn build_models_fetch_url(
|
|||||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
let url = if provider_type == "codex" && api_format.starts_with("openai:") {
|
let url = if provider_type == "codex" && api_format.starts_with("openai:") {
|
||||||
build_codex_models_url(base_url)
|
build_codex_models_url(base_url)
|
||||||
} else if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
} else if api_format.starts_with("openai:") {
|
||||||
build_v1_models_url(base_url)
|
build_v1_models_url(base_url)
|
||||||
|
} else if api_format.starts_with("claude:") {
|
||||||
|
build_claude_models_url(base_url)
|
||||||
} else if api_format.starts_with("gemini:") {
|
} else if api_format.starts_with("gemini:") {
|
||||||
build_gemini_models_url(base_url)
|
build_gemini_models_url(base_url)
|
||||||
} else {
|
} else {
|
||||||
@@ -600,17 +607,48 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_v1_models_url(base_url: &str) -> Option<String> {
|
fn build_v1_models_url(base_url: &str) -> Option<String> {
|
||||||
let (trimmed_base_url, query) = split_url_query(base_url);
|
build_openai_compatible_models_url(base_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_claude_models_url(base_url: &str) -> Option<String> {
|
||||||
|
if let Some(url) = build_deepseek_anthropic_models_url(base_url) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (trimmed_base_url, base_query) = split_url_query(base_url);
|
||||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
if trimmed_base_url.is_empty() {
|
if trimmed_base_url.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut url = if trimmed_base_url.ends_with("/v1") {
|
let mut url = if trimmed_base_url.ends_with("/v1") {
|
||||||
format!("{trimmed_base_url}/models")
|
format!("{trimmed_base_url}/models")
|
||||||
} else {
|
} else {
|
||||||
format!("{trimmed_base_url}/v1/models")
|
format!("{trimmed_base_url}/v1/models")
|
||||||
};
|
};
|
||||||
if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
|
if let Some(query) = base_query.filter(|value| !value.trim().is_empty()) {
|
||||||
|
url.push('?');
|
||||||
|
url.push_str(query);
|
||||||
|
}
|
||||||
|
Some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deepseek_anthropic_models_fetch_uses_openai_auth(base_url: &str) -> bool {
|
||||||
|
build_deepseek_anthropic_models_url(base_url).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_deepseek_anthropic_models_url(base_url: &str) -> Option<String> {
|
||||||
|
let (trimmed_base_url, base_query) = split_url_query(base_url);
|
||||||
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
|
let normalized = trimmed_base_url.to_ascii_lowercase();
|
||||||
|
if normalized != "https://api.deepseek.com/anthropic"
|
||||||
|
&& normalized != "https://api.deepseek.com/anthropic/v1"
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut url = "https://api.deepseek.com/models".to_string();
|
||||||
|
if let Some(query) = base_query.filter(|value| !value.trim().is_empty()) {
|
||||||
url.push('?');
|
url.push('?');
|
||||||
url.push_str(query);
|
url.push_str(query);
|
||||||
}
|
}
|
||||||
@@ -618,11 +656,21 @@ fn build_v1_models_url(base_url: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_codex_models_url(base_url: &str) -> Option<String> {
|
fn build_codex_models_url(base_url: &str) -> Option<String> {
|
||||||
|
if let Some(url) = build_bigmodel_coding_models_url(base_url) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
let (trimmed_base_url, query) = split_url_query(base_url);
|
let (trimmed_base_url, query) = split_url_query(base_url);
|
||||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
if trimmed_base_url.is_empty() {
|
if trimmed_base_url.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let is_codex_backend = is_codex_cli_backend_url(trimmed_base_url)
|
||||||
|
|| trimmed_base_url.ends_with("/codex")
|
||||||
|
|| trimmed_base_url.ends_with("/models");
|
||||||
|
if !is_codex_backend && openai_compatible_base_includes_unversioned_api_root(base_url) {
|
||||||
|
return build_openai_compatible_models_url(base_url);
|
||||||
|
}
|
||||||
let mut url = if trimmed_base_url.ends_with("/models") {
|
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||||
trimmed_base_url.to_string()
|
trimmed_base_url.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -965,6 +1013,90 @@ 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".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".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("openai", "openai:chat", "https://proxy.example.com/openai"),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/openai/models".to_string(),
|
||||||
|
"openai:chat".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url("openai", "openai:chat", "https://proxy.example.com"),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/v1/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()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url(
|
||||||
|
"anthropic",
|
||||||
|
"claude:messages",
|
||||||
|
"https://proxy.example.com/api"
|
||||||
|
),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/api/v1/models".to_string(),
|
||||||
|
"claude:messages".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_models_fetch_url_uses_deepseek_openai_models_for_anthropic_base() {
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url(
|
||||||
|
"custom",
|
||||||
|
"claude:messages",
|
||||||
|
"https://api.deepseek.com/anthropic"
|
||||||
|
),
|
||||||
|
Some((
|
||||||
|
"https://api.deepseek.com/models".to_string(),
|
||||||
|
"claude:messages".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_models_response_normalizes_openai_payload() {
|
fn parse_models_response_normalizes_openai_payload() {
|
||||||
let parsed = parse_models_response(
|
let parsed = parse_models_response(
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use serde_json::{json, Value};
|
|||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
|
||||||
use crate::logic::{
|
use crate::logic::{
|
||||||
extract_error_message, parse_models_response_page, parse_windsurf_model_configs_response,
|
aggregate_models_for_cache, extract_error_message, parse_models_response_page,
|
||||||
preset_models_for_provider,
|
parse_windsurf_model_configs_response, preset_models_for_provider,
|
||||||
};
|
};
|
||||||
use crate::transport::{
|
use crate::transport::{
|
||||||
build_antigravity_fetch_available_models_plan, build_gemini_cli_load_code_assist_plan,
|
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(
|
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) {
|
for base_url in iter_vertex_base_urls(transports) {
|
||||||
let url = build_vertex_google_list_url(&base_url, api_key, None);
|
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,
|
runtime,
|
||||||
reference_transport,
|
reference_transport,
|
||||||
&url,
|
&url,
|
||||||
@@ -442,7 +443,14 @@ async fn fetch_vertex_api_key_models(
|
|||||||
"gemini:generate_content",
|
"gemini:generate_content",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(err) => {
|
||||||
|
hard_errors.push(format!("{base_url}: {err}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
has_success |= outcome.has_success;
|
has_success |= outcome.has_success;
|
||||||
if let Some(error) = outcome.error {
|
if let Some(error) = outcome.error {
|
||||||
if is_soft_not_found(&error) {
|
if is_soft_not_found(&error) {
|
||||||
@@ -507,7 +515,7 @@ async fn fetch_vertex_service_account_models(
|
|||||||
("anthropic", claude_transport, "claude:messages"),
|
("anthropic", claude_transport, "claude:messages"),
|
||||||
] {
|
] {
|
||||||
let url = build_vertex_service_account_list_url(&base, publisher, None);
|
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,
|
runtime,
|
||||||
transport,
|
transport,
|
||||||
&url,
|
&url,
|
||||||
@@ -516,7 +524,14 @@ async fn fetch_vertex_service_account_models(
|
|||||||
api_format,
|
api_format,
|
||||||
Some(("authorization".to_string(), format!("Bearer {token}"))),
|
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;
|
has_success |= outcome.has_success;
|
||||||
if let Some(error) = outcome.error {
|
if let Some(error) = outcome.error {
|
||||||
let labeled = format!("{url}: {error}");
|
let labeled = format!("{url}: {error}");
|
||||||
@@ -1298,12 +1313,20 @@ mod tests {
|
|||||||
use crate::fetch_models_from_transports;
|
use crate::fetch_models_from_transports;
|
||||||
use crate::transport::ModelFetchTransportRuntime;
|
use crate::transport::ModelFetchTransportRuntime;
|
||||||
|
|
||||||
|
type RouteResult = Result<(u16, Value), String>;
|
||||||
|
type ModelFetchRoute = (String, RouteResult);
|
||||||
|
|
||||||
struct TestRuntime {
|
struct TestRuntime {
|
||||||
executed_urls: Arc<Mutex<Vec<String>>>,
|
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||||
response_body: Value,
|
response_body: Value,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RoutingTestRuntime {
|
||||||
|
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||||
|
routes: Vec<ModelFetchRoute>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ModelFetchTransportRuntime for TestRuntime {
|
impl ModelFetchTransportRuntime for TestRuntime {
|
||||||
async fn resolve_local_oauth_request_auth(
|
async fn resolve_local_oauth_request_auth(
|
||||||
@@ -1344,6 +1367,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 {
|
fn sample_custom_aiplatform_transport() -> GatewayProviderTransportSnapshot {
|
||||||
GatewayProviderTransportSnapshot {
|
GatewayProviderTransportSnapshot {
|
||||||
provider: GatewayProviderTransportProvider {
|
provider: GatewayProviderTransportProvider {
|
||||||
@@ -1455,6 +1529,27 @@ mod tests {
|
|||||||
transport
|
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]
|
#[test]
|
||||||
fn strategy_selection_keeps_codex_on_standard_transport_fetch() {
|
fn strategy_selection_keeps_codex_on_standard_transport_fetch() {
|
||||||
let strategy = select_model_fetch_strategy(&[sample_codex_transport()])
|
let strategy = select_model_fetch_strategy(&[sample_codex_transport()])
|
||||||
@@ -1525,6 +1620,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]
|
#[test]
|
||||||
fn vertex_model_fetch_uses_model_garden_list_endpoint() {
|
fn vertex_model_fetch_uses_model_garden_list_endpoint() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use aether_provider_transport::{
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::build_models_fetch_url;
|
use crate::{build_models_fetch_url, deepseek_anthropic_models_fetch_uses_openai_auth};
|
||||||
|
|
||||||
const OPENAI_RESPONSES_USER_AGENT: &str = "openai-codex/1.0";
|
const OPENAI_RESPONSES_USER_AGENT: &str = "openai-codex/1.0";
|
||||||
const CLAUDE_CLI_USER_AGENT: &str = "claude-code/1.0.1";
|
const CLAUDE_CLI_USER_AGENT: &str = "claude-code/1.0.1";
|
||||||
@@ -98,19 +98,29 @@ pub async fn build_standard_models_fetch_execution_plan(
|
|||||||
let provider_type = transport.provider.provider_type.trim().to_ascii_lowercase();
|
let provider_type = transport.provider.provider_type.trim().to_ascii_lowercase();
|
||||||
let is_codex_openai_models_fetch =
|
let is_codex_openai_models_fetch =
|
||||||
provider_type == "codex" && api_format.starts_with("openai:");
|
provider_type == "codex" && api_format.starts_with("openai:");
|
||||||
|
let is_deepseek_anthropic_models_fetch = api_format.starts_with("claude:")
|
||||||
|
&& deepseek_anthropic_models_fetch_uses_openai_auth(&transport.endpoint.base_url);
|
||||||
let mut headers = standard_models_fetch_headers(&api_format, &provider_type);
|
let mut headers = standard_models_fetch_headers(&api_format, &provider_type);
|
||||||
if is_codex_openai_models_fetch {
|
if is_codex_openai_models_fetch {
|
||||||
headers.insert("accept".to_string(), "application/json".to_string());
|
headers.insert("accept".to_string(), "application/json".to_string());
|
||||||
}
|
}
|
||||||
|
if is_deepseek_anthropic_models_fetch {
|
||||||
|
headers.remove("anthropic-version");
|
||||||
|
headers.insert("accept".to_string(), "application/json".to_string());
|
||||||
|
}
|
||||||
let mut protected_headers = Vec::<String>::new();
|
let mut protected_headers = Vec::<String>::new();
|
||||||
|
|
||||||
if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
||||||
let (auth_header_name, auth_header_value) =
|
let resolved_auth = if is_deepseek_anthropic_models_fetch {
|
||||||
resolve_standard_header_auth(runtime, transport)
|
resolve_oauth_header_auth(runtime, transport)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.or_else(|| resolve_local_openai_bearer_auth(transport))
|
||||||
"Rust models fetch auth resolution is not supported for this key".to_string()
|
} else {
|
||||||
})?;
|
resolve_standard_header_auth(runtime, transport).await?
|
||||||
|
};
|
||||||
|
let (auth_header_name, auth_header_value) = resolved_auth.ok_or_else(|| {
|
||||||
|
"Rust models fetch auth resolution is not supported for this key".to_string()
|
||||||
|
})?;
|
||||||
insert_non_empty_auth_header(
|
insert_non_empty_auth_header(
|
||||||
&mut headers,
|
&mut headers,
|
||||||
&mut protected_headers,
|
&mut protected_headers,
|
||||||
@@ -590,7 +600,9 @@ fn build_standard_models_fetch_url(
|
|||||||
)
|
)
|
||||||
.ok_or_else(|| "Rust models fetch does not support this provider format yet".to_string())?;
|
.ok_or_else(|| "Rust models fetch does not support this provider format yet".to_string())?;
|
||||||
|
|
||||||
if api_format.starts_with("claude:") {
|
if api_format.starts_with("claude:")
|
||||||
|
&& !deepseek_anthropic_models_fetch_uses_openai_auth(&transport.endpoint.base_url)
|
||||||
|
{
|
||||||
url = append_query_param(url, "limit", "100");
|
url = append_query_param(url, "limit", "100");
|
||||||
if let Some(after_id) = after_id.map(str::trim).filter(|value| !value.is_empty()) {
|
if let Some(after_id) = after_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
url = append_query_param(url, "after_id", after_id);
|
url = append_query_param(url, "after_id", after_id);
|
||||||
@@ -807,6 +819,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"
|
||||||
|
);
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn builds_codex_models_fetch_plan_with_account_header() {
|
async fn builds_codex_models_fetch_plan_with_account_header() {
|
||||||
let runtime = TestRuntime {
|
let runtime = TestRuntime {
|
||||||
@@ -871,6 +926,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builds_deepseek_anthropic_models_fetch_plan_with_openai_models_endpoint() {
|
||||||
|
let runtime = TestRuntime {
|
||||||
|
oauth_auth: None,
|
||||||
|
proxy: None,
|
||||||
|
};
|
||||||
|
let mut transport = sample_transport("custom", "claude:messages", "api_key");
|
||||||
|
transport.endpoint.base_url = "https://api.deepseek.com/anthropic".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://api.deepseek.com/models");
|
||||||
|
assert_eq!(
|
||||||
|
plan.headers.get("authorization").map(String::as_str),
|
||||||
|
Some("Bearer secret")
|
||||||
|
);
|
||||||
|
assert!(!plan.headers.contains_key("x-api-key"));
|
||||||
|
assert!(!plan.headers.contains_key("anthropic-version"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn builds_gemini_models_fetch_plan_with_browser_headers_and_query_auth() {
|
async fn builds_gemini_models_fetch_plan_with_browser_headers_and_query_auth() {
|
||||||
let runtime = TestRuntime {
|
let runtime = TestRuntime {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use crate::claude_code::build_claude_code_messages_url;
|
|||||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||||
use crate::url::{
|
use crate::url::{
|
||||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||||
build_openai_responses_url, build_passthrough_path_url,
|
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
||||||
google_openai_compat_base_includes_api_root, normalize_gemini_content_action_path,
|
openai_compatible_base_includes_api_root,
|
||||||
};
|
};
|
||||||
use crate::vertex::{
|
use crate::vertex::{
|
||||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
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())
|
.unwrap_or_else(|| upstream_base_url.trim())
|
||||||
.trim_end_matches('/');
|
.trim_end_matches('/');
|
||||||
let path = if base_without_query.ends_with("/v1")
|
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
|
v1_path
|
||||||
} else {
|
} else {
|
||||||
@@ -1199,7 +1199,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("https://api.openai.example/root/v1/embeddings?tenant=request&trace=1")
|
Some("https://api.openai.example/root/embeddings?tenant=request&trace=1")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_transport_request_url(
|
build_transport_request_url(
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ use url::Url;
|
|||||||
pub fn build_openai_chat_url(upstream_base_url: &str, query: Option<&str>) -> String {
|
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, base_query) = split_base_url_query(upstream_base_url);
|
||||||
let trimmed = trimmed.trim_end_matches('/');
|
let trimmed = trimmed.trim_end_matches('/');
|
||||||
let mut url =
|
let mut url = if openai_compatible_base_includes_api_root(trimmed) {
|
||||||
if trimmed.ends_with("/v1") || google_openai_compat_base_includes_api_root(trimmed) {
|
format!("{trimmed}/chat/completions")
|
||||||
format!("{trimmed}/chat/completions")
|
} else {
|
||||||
} else {
|
format!("{trimmed}/v1/chat/completions")
|
||||||
format!("{trimmed}/v1/chat/completions")
|
};
|
||||||
};
|
|
||||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
@@ -31,7 +30,7 @@ pub fn build_openai_responses_url(
|
|||||||
};
|
};
|
||||||
let mut url = if is_codex_cli_backend_url(trimmed)
|
let mut url = if is_codex_cli_backend_url(trimmed)
|
||||||
|| trimmed.ends_with("/codex")
|
|| trimmed.ends_with("/codex")
|
||||||
|| trimmed.ends_with("/v1")
|
|| openai_compatible_base_includes_api_root(trimmed)
|
||||||
{
|
{
|
||||||
format!("{trimmed}{suffix}")
|
format!("{trimmed}{suffix}")
|
||||||
} else {
|
} else {
|
||||||
@@ -51,7 +50,7 @@ pub fn build_openai_image_url(
|
|||||||
let suffix = openai_image_path_suffix(request_path);
|
let suffix = openai_image_path_suffix(request_path);
|
||||||
let mut url = if openai_image_base_includes_operation_path(trimmed) {
|
let mut url = if openai_image_base_includes_operation_path(trimmed) {
|
||||||
trimmed.to_string()
|
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}")
|
format!("{trimmed}{suffix}")
|
||||||
} else {
|
} else {
|
||||||
format!("{trimmed}/v1{suffix}")
|
format!("{trimmed}/v1{suffix}")
|
||||||
@@ -200,6 +199,46 @@ pub fn build_passthrough_path_url(
|
|||||||
Some(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") {
|
||||||
|
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 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(
|
pub fn build_gemini_files_passthrough_url(
|
||||||
upstream_base_url: &str,
|
upstream_base_url: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -254,6 +293,61 @@ pub(crate) fn google_openai_compat_base_includes_api_root(base_url: &str) -> boo
|
|||||||
false
|
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 {
|
fn looks_like_vertex_ai_host(host: &str) -> bool {
|
||||||
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
|
const VERTEX_AI_HOST: &str = "aiplatform.googleapis.com";
|
||||||
host == VERTEX_AI_HOST
|
host == VERTEX_AI_HOST
|
||||||
@@ -343,8 +437,9 @@ fn merge_query_string(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_gemini_content_url, build_gemini_files_passthrough_url,
|
build_bigmodel_coding_models_url, build_claude_messages_url, build_gemini_content_url,
|
||||||
build_gemini_video_predict_long_running_url, build_openai_chat_url, build_openai_image_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,
|
build_openai_responses_url, build_passthrough_path_url,
|
||||||
normalize_gemini_content_action_path,
|
normalize_gemini_content_action_path,
|
||||||
};
|
};
|
||||||
@@ -378,6 +473,102 @@ 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_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/v1/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_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/v1/messages"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://proxy.example.com/anthropic", None),
|
||||||
|
"https://proxy.example.com/anthropic/v1/messages"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://api.anthropic.example", None),
|
||||||
|
"https://api.anthropic.example/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/v1/models")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_responses_url_preserves_codex_path_prefix() {
|
fn openai_responses_url_preserves_codex_path_prefix() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -580,6 +580,7 @@ export interface ProviderModelsQueryResponse {
|
|||||||
model_test_capabilities?: ModelTestCapabilities | null
|
model_test_capabilities?: ModelTestCapabilities | null
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
|
warning?: string
|
||||||
from_cache?: boolean
|
from_cache?: boolean
|
||||||
}
|
}
|
||||||
provider: {
|
provider: {
|
||||||
|
|||||||
@@ -357,8 +357,8 @@ async function applyAutoMatchFromKey(key: AutoMatchKey) {
|
|||||||
const result = await fetchCachedModels(props.providerId, key.id, true)
|
const result = await fetchCachedModels(props.providerId, key.id, true)
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
|
|
||||||
if (result.error && result.models.length > 0) {
|
if (result.warning) {
|
||||||
showWarning(`部分格式获取失败: ${result.error}`)
|
showWarning(`部分格式获取失败: ${result.warning}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.models.length === 0) {
|
if (result.models.length === 0) {
|
||||||
|
|||||||
@@ -153,10 +153,16 @@
|
|||||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||||
<Input
|
<Input
|
||||||
:model-value="getDisplayedPath(endpoint)"
|
:model-value="getDisplayedPath(endpoint)"
|
||||||
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
|
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
|
||||||
:disabled="isFixedProvider"
|
|
||||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="getEndpointDefaultPath(endpoint)"
|
||||||
|
class="text-[10px] text-muted-foreground truncate"
|
||||||
|
:title="getEndpointDefaultPath(endpoint)"
|
||||||
|
>
|
||||||
|
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
||||||
@@ -963,6 +969,13 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
:placeholder="newEndpointDefaultPath || '留空使用默认'"
|
:placeholder="newEndpointDefaultPath || '留空使用默认'"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="newEndpointDefaultPath"
|
||||||
|
class="text-[10px] text-muted-foreground truncate"
|
||||||
|
:title="newEndpointDefaultPath"
|
||||||
|
>
|
||||||
|
当前默认路径:{{ newEndpointDefaultPath }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1859,10 +1872,11 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
|
||||||
|
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
|
||||||
|
}
|
||||||
|
|
||||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||||
if (isFixedProvider.value) {
|
|
||||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
|
||||||
}
|
|
||||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3199,13 +3213,13 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
|||||||
|
|
||||||
savingEndpointId.value = endpoint.id
|
savingEndpointId.value = endpoint.id
|
||||||
try {
|
try {
|
||||||
// 仅提交变更字段,避免 fixed provider 因 base_url/custom_path 被锁定而更新失败
|
// 仅提交变更字段;fixed provider 锁定 base_url,但允许覆盖 custom_path。
|
||||||
const payload: Record<string, unknown> = {}
|
const payload: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (!isFixedProvider.value) {
|
if (!isFixedProvider.value) {
|
||||||
if (state.url !== endpoint.base_url) payload.base_url = state.url
|
if (state.url !== endpoint.base_url) payload.base_url = state.url
|
||||||
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
|
|
||||||
}
|
}
|
||||||
|
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
|
||||||
|
|
||||||
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
|
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
|
||||||
if (hasResponseHeaderRulesChanges(endpoint)) {
|
if (hasResponseHeaderRulesChanges(endpoint)) {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ const emit = defineEmits<{
|
|||||||
saved: []
|
saved: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
@@ -295,9 +295,8 @@ async function fetchUpstreamModels() {
|
|||||||
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
|
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
|
||||||
.map((m: UpstreamModel) => m.id)
|
.map((m: UpstreamModel) => m.id)
|
||||||
hasQueried.value = true
|
hasQueried.value = true
|
||||||
// 如果有部分失败,显示警告提示
|
if (result.warning) {
|
||||||
if (result.error) {
|
showWarning(result.warning, '部分格式获取失败')
|
||||||
showError(`部分格式获取失败: ${result.error}`, '警告')
|
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
errorMessage.value = result.error
|
errorMessage.value = result.error
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ const emit = defineEmits<{
|
|||||||
saved: []
|
saved: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { confirmWarning } = useConfirm()
|
const { confirmWarning } = useConfirm()
|
||||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
@@ -691,6 +691,9 @@ async function fetchUpstreamModels(forceRefresh = false) {
|
|||||||
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
||||||
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
|
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
|
||||||
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
||||||
|
if (result.warning) {
|
||||||
|
showWarning(result.warning, '部分格式获取失败')
|
||||||
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
showError(result.error, '获取上游模型失败')
|
showError(result.error, '获取上游模型失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ const emit = defineEmits<{
|
|||||||
'saved': []
|
'saved': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
type EndpointOption = {
|
type EndpointOption = {
|
||||||
@@ -564,6 +564,9 @@ async function fetchUpstreamModels() {
|
|||||||
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
||||||
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
||||||
}
|
}
|
||||||
|
if (result.warning) {
|
||||||
|
showWarning(result.warning, '部分格式获取失败')
|
||||||
|
}
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
showError(result.error, '获取上游模型失败')
|
showError(result.error, '获取上游模型失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { getDefaultEndpointPath } from '../endpoint-default-paths'
|
import { getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||||
|
|
||||||
const apiFormats = [
|
const apiFormats = [
|
||||||
|
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
|
||||||
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
|
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
|
||||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
||||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||||
|
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||||
|
{ value: 'claude:messages', default_path: '/v1/messages' },
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('endpoint default paths', () => {
|
describe('endpoint default paths', () => {
|
||||||
@@ -44,4 +47,73 @@ describe('endpoint default paths', () => {
|
|||||||
apiFormats,
|
apiFormats,
|
||||||
})).toBe('/responses')
|
})).toBe('/responses')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/api',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/chat/completions')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:embedding',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/embeddings')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/openai',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/chat/completions')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/v1/chat/completions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/chat/completions')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:responses',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://api.openai.example/v1',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/responses')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps /v1 for Claude Messages defaults unless base URL already ends with v1', () => {
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://api.anthropic.example/v1',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/messages')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/api',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/v1/messages')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/anthropic',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/v1/messages')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,75 @@ function isCodexUrl(baseUrl: string): boolean {
|
|||||||
return url.includes('/backend-api/codex') || url.endsWith('/codex')
|
return url.includes('/backend-api/codex') || url.endsWith('/codex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBaseUrlParts(baseUrl?: string | null): { host: string; path: string } | null {
|
||||||
|
const raw = (baseUrl || '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw)
|
||||||
|
return {
|
||||||
|
host: parsed.hostname.toLowerCase(),
|
||||||
|
path: parsed.pathname.replace(/\/+$/, '').toLowerCase(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const pathStart = raw.indexOf('/')
|
||||||
|
return {
|
||||||
|
host: '',
|
||||||
|
path: pathStart >= 0 ? raw.slice(pathStart).split('?')[0].replace(/\/+$/, '').toLowerCase() : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrlHasPathApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const path = parseBaseUrlParts(baseUrl)?.path
|
||||||
|
return !!path && path !== '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
|
||||||
|
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return parts?.host === 'generativelanguage.googleapis.com'
|
||||||
|
&& (parts.path === '/v1beta/openai' || parts.path === '/v1/openai')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVertexOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return !!parts
|
||||||
|
&& (parts.host === 'aiplatform.googleapis.com' || parts.host.endsWith('.aiplatform.googleapis.com') || parts.host.endsWith('-aiplatform.googleapis.com'))
|
||||||
|
&& parts.path.endsWith('/endpoints/openapi')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
return baseUrlEndsWithV1Root(baseUrl)
|
||||||
|
|| baseUrlHasPathApiRoot(baseUrl)
|
||||||
|
|| isBigModelCodingApiRoot(baseUrl)
|
||||||
|
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||||
|
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
return baseUrlEndsWithV1Root(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripV1PrefixForApiRoot(path: string): string {
|
||||||
|
return path.replace(/^\/v1(?=\/)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
|
||||||
|
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClaudeCompatibleFormat(apiFormat: string): boolean {
|
||||||
|
return apiFormat === 'claude:messages'
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultEndpointPath(params: {
|
export function getDefaultEndpointPath(params: {
|
||||||
apiFormat: string
|
apiFormat: string
|
||||||
providerType?: string | null
|
providerType?: string | null
|
||||||
@@ -43,5 +112,11 @@ export function getDefaultEndpointPath(params: {
|
|||||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||||
return '/responses'
|
return '/responses'
|
||||||
}
|
}
|
||||||
|
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||||
|
return stripV1PrefixForApiRoot(defaultPath)
|
||||||
|
}
|
||||||
|
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
|
||||||
|
return stripV1PrefixForApiRoot(defaultPath)
|
||||||
|
}
|
||||||
return defaultPath
|
return defaultPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { UpstreamModel } from '@/api/endpoints/types'
|
|||||||
|
|
||||||
export type { UpstreamModel }
|
export type { UpstreamModel }
|
||||||
|
|
||||||
type FetchResult = { models: UpstreamModel[]; error?: string; fromCache?: boolean }
|
type FetchResult = { models: UpstreamModel[]; error?: string; warning?: string; fromCache?: boolean }
|
||||||
|
|
||||||
// 进行中的请求(用于去重并发请求)
|
// 进行中的请求(用于去重并发请求)
|
||||||
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||||
@@ -54,14 +54,14 @@ export function useUpstreamModelsCache() {
|
|||||||
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
|
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
|
||||||
|
|
||||||
if (response.success && response.data?.models) {
|
if (response.success && response.data?.models) {
|
||||||
|
const partialWarning = response.data.warning ?? response.data.error
|
||||||
return {
|
return {
|
||||||
models: response.data.models,
|
models: response.data.models,
|
||||||
// 传递部分格式获取失败的 warning(后端 success=true 但仍可能附带 error)
|
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
|
||||||
error: response.data.error ? parseUpstreamModelError(response.data.error) : undefined,
|
|
||||||
fromCache: response.data.from_cache
|
fromCache: response.data.from_cache
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const rawError = response.data?.error || '获取上游模型失败'
|
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
|
||||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user