Normalize canonical API formats

This commit is contained in:
fawney19
2026-04-29 09:25:19 +08:00
parent 02ad67fe33
commit 07a319259b
171 changed files with 2460 additions and 1962 deletions

View File

@@ -130,17 +130,17 @@ async fn admin_gemini_files_upload_single_key(
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
.eq_ignore_ascii_case("gemini:generate_content")
})
}) else {
return Err("找不到有效的 gemini:chat 端点".to_string());
return Err("找不到有效的 gemini:generate_content 端点".to_string());
};
let transport = state
.read_provider_transport_snapshot(&key.provider_id, &endpoint.id, &key.id)
.await
.map_err(|err| format!("{err:?}"))?
.ok_or_else(|| "无法读取 Key 传输配置".to_string())?;
if !state.supports_local_gemini_transport_with_network(&transport, "gemini:chat") {
if !state.supports_local_gemini_transport_with_network(&transport, "gemini:generate_content") {
return Err("Key 传输配置不支持 Gemini Files 上传".to_string());
}
if transport.endpoint.body_rules.is_some() {

View File

@@ -94,14 +94,10 @@ pub(super) async fn maybe_handle(
}),
"antigravity" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& (endpoint
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
.eq_ignore_ascii_case("gemini:generate_content")
}),
"kiro" => endpoints
.iter()
@@ -110,7 +106,7 @@ pub(super) async fn maybe_handle(
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:cli")
.eq_ignore_ascii_case("claude:messages")
})
.cloned()
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
@@ -120,7 +116,7 @@ pub(super) async fn maybe_handle(
let Some(endpoint) = endpoint else {
let detail = match normalized_provider_type.as_str() {
"codex" => "找不到有效的 openai:responses 端点",
"antigravity" => "找不到有效的 gemini:chat/gemini:cli 端点",
"antigravity" => "找不到有效的 gemini:generate_content 端点",
"kiro" => "找不到有效的 Kiro 端点",
_ => "找不到有效端点",
};

View File

@@ -68,7 +68,7 @@ async fn execute_antigravity_quota_plan(
body_ref: None,
},
stream: false,
client_api_format: "gemini:chat".to_string(),
client_api_format: "gemini:generate_content".to_string(),
provider_api_format: "antigravity:fetch_available_models".to_string(),
model_name: Some("fetchAvailableModels".to_string()),
proxy,

View File

@@ -99,7 +99,7 @@ pub(super) async fn execute_kiro_quota_plan(
body_ref: None,
},
stream: false,
client_api_format: "claude:cli".to_string(),
client_api_format: "claude:messages".to_string(),
provider_api_format: "kiro:usage".to_string(),
model_name: Some("kiro-usage-limits".to_string()),
proxy,

View File

@@ -26,14 +26,10 @@ pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
.iter()
.find(|endpoint| {
endpoint.is_active
&& (endpoint
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
.eq_ignore_ascii_case("gemini:generate_content")
})
.cloned(),
"kiro" => endpoints
@@ -43,7 +39,7 @@ pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:cli")
.eq_ignore_ascii_case("claude:messages")
})
.cloned()
.or_else(|| {

View File

@@ -289,7 +289,7 @@ fn provider_query_key_supports_endpoint(
}
fn provider_query_normalize_api_format_alias(value: &str) -> String {
crate::ai_pipeline::normalize_legacy_openai_format_alias(value)
crate::ai_pipeline::normalize_api_format_alias(value)
}
fn provider_query_transport_supports_standard_test_execution(
@@ -297,7 +297,7 @@ fn provider_query_transport_supports_standard_test_execution(
transport: &AdminGatewayProviderTransportSnapshot,
api_format: &str,
) -> bool {
match crate::ai_pipeline::normalize_legacy_openai_format_alias(api_format).as_str() {
match crate::ai_pipeline::normalize_api_format_alias(api_format).as_str() {
"openai:chat" => {
crate::provider_transport::policy::supports_local_openai_chat_transport(transport)
}
@@ -306,12 +306,12 @@ fn provider_query_transport_supports_standard_test_execution(
transport, api_format,
)
}
"claude:chat" | "claude:cli" => {
"claude:messages" => {
crate::provider_transport::policy::supports_local_standard_transport_with_network(
transport, api_format,
)
}
"gemini:chat" | "gemini:cli" => {
"gemini:generate_content" => {
if crate::provider_transport::is_vertex_api_key_transport_context(transport) {
aether_provider_transport::vertex::supports_local_vertex_api_key_gemini_transport_with_network(transport)
} else {
@@ -930,7 +930,7 @@ async fn provider_query_execute_standard_test_candidate(
let provider_api_format = candidate.endpoint.api_format.as_str();
let normalized_provider_api_format =
crate::ai_pipeline::normalize_legacy_openai_format_alias(provider_api_format);
crate::ai_pipeline::normalize_api_format_alias(provider_api_format);
let provider_request_body = match normalized_provider_api_format.as_str() {
"openai:chat" => {
let Some(mut provider_request_body) =
@@ -957,7 +957,7 @@ async fn provider_query_execute_standard_test_candidate(
}
provider_request_body
}
"claude:chat" | "claude:cli" | "gemini:chat" | "gemini:cli" => {
"claude:messages" | "gemini:generate_content" => {
let Some(mut provider_request_body) =
crate::ai_pipeline::build_cross_format_openai_chat_request_body(
&request_body,
@@ -1035,26 +1035,22 @@ async fn provider_query_execute_standard_test_candidate(
} else {
None
};
let oauth_auth = match crate::ai_pipeline::normalize_legacy_openai_format_alias(
provider_api_format,
)
.as_str()
{
"openai:chat" | "openai:responses" | "claude:chat" | "claude:cli" | "gemini:chat"
| "gemini:cli" => state.resolve_local_oauth_header_auth(&transport).await?,
_ => None,
};
let auth = match crate::ai_pipeline::normalize_legacy_openai_format_alias(provider_api_format)
.as_str()
{
let oauth_auth =
match crate::ai_pipeline::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" | "claude:messages" | "gemini:generate_content" => {
state.resolve_local_oauth_header_auth(&transport).await?
}
_ => None,
};
let auth = match crate::ai_pipeline::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" => {
crate::provider_transport::auth::resolve_local_openai_bearer_auth(&transport)
.or(oauth_auth)
}
"claude:chat" | "claude:cli" => {
"claude:messages" => {
crate::provider_transport::auth::resolve_local_standard_auth(&transport).or(oauth_auth)
}
"gemini:chat" | "gemini:cli" => {
"gemini:generate_content" => {
if uses_vertex_query_auth {
oauth_auth
} else {
@@ -1099,15 +1095,13 @@ async fn provider_query_execute_standard_test_candidate(
};
let mut request_headers = match provider_api_format {
"claude:chat" | "claude:cli" => {
crate::provider_transport::auth::build_claude_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&BTreeMap::new(),
Some("application/json"),
)
}
"claude:messages" => crate::provider_transport::auth::build_claude_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&BTreeMap::new(),
Some("application/json"),
),
"openai:responses" => {
crate::provider_transport::auth::build_complete_passthrough_headers_with_auth(
&parts.headers,
@@ -1262,13 +1256,16 @@ fn provider_query_test_attempt_payload(
}
fn provider_query_prefers_chat_standard_test_api_format(api_format: &str) -> bool {
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
matches!(
api_format,
"openai:chat" | "claude:messages" | "gemini:generate_content"
)
}
fn provider_query_supports_cli_standard_test_api_format(api_format: &str) -> bool {
matches!(
crate::ai_pipeline::normalize_legacy_openai_format_alias(api_format).as_str(),
"openai:responses" | "claude:cli" | "gemini:cli"
crate::ai_pipeline::normalize_api_format_alias(api_format).as_str(),
"openai:responses" | "claude:messages" | "gemini:generate_content"
)
}

View File

@@ -1,6 +1,7 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRequest;
use crate::handlers::admin::provider::write::normalize::{
normalize_auth_type, validate_vertex_api_formats,
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -26,8 +27,10 @@ pub(crate) async fn build_admin_create_provider_key_record(
return Err("name 为必填字段".to_string());
}
let api_formats = normalize_string_list(payload.api_formats)
.ok_or_else(|| "api_formats 为必填字段".to_string())?;
let api_formats = normalize_api_format_list(
normalize_string_list(payload.api_formats)
.ok_or_else(|| "api_formats 为必填字段".to_string())?,
);
let auth_type = normalize_auth_type(payload.auth_type.as_deref())?;
validate_vertex_api_formats(&provider.provider_type, &auth_type, &api_formats)?;
@@ -149,7 +152,7 @@ pub(crate) async fn build_admin_create_provider_key_record(
},
encrypted_api_key,
encrypted_auth_config,
normalize_json_object(payload.rate_multipliers, "rate_multipliers")?,
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?,
None,
normalize_string_list(payload.allowed_models).map(|value| json!(value)),
None,

View File

@@ -1,6 +1,7 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::provider::write::normalize::{
normalize_auth_type, validate_vertex_api_formats,
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -190,8 +191,10 @@ pub(crate) async fn build_admin_update_provider_key_record(
}
if fields.contains("api_formats") {
let api_formats = normalize_string_list(payload.api_formats)
.ok_or_else(|| "api_formats 为必填字段".to_string())?;
let api_formats = normalize_api_format_list(
normalize_string_list(payload.api_formats)
.ok_or_else(|| "api_formats 为必填字段".to_string())?,
);
if managed_fixed_oauth_key {
updated.api_formats = None;
} else {
@@ -202,7 +205,8 @@ pub(crate) async fn build_admin_update_provider_key_record(
if managed_fixed_oauth_key {
updated.api_formats = None;
} else {
let api_formats = json_string_list(existing.api_formats.as_ref());
let api_formats =
normalize_api_format_list(json_string_list(existing.api_formats.as_ref()));
validate_vertex_api_formats(&provider.provider_type, &target_auth_type, &api_formats)?;
}
}
@@ -218,13 +222,13 @@ pub(crate) async fn build_admin_update_provider_key_record(
}
if fields.contains("rate_multipliers") {
updated.rate_multipliers =
normalize_json_object(payload.rate_multipliers, "rate_multipliers")?;
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?;
}
if let Some(internal_priority) = payload.internal_priority {
updated.internal_priority = internal_priority;
}
if fields.contains("global_priority_by_format") {
updated.global_priority_by_format = normalize_json_object(
updated.global_priority_by_format = normalize_api_format_json_object_keys(
payload.global_priority_by_format,
"global_priority_by_format",
)?;

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeSet;
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
@@ -10,6 +12,36 @@ pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, Strin
}
}
pub(crate) fn normalize_api_format_list(values: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut normalized = Vec::new();
for value in values {
let canonical = crate::ai_pipeline::normalize_api_format_alias(&value);
if seen.insert(canonical.clone()) {
normalized.push(canonical);
}
}
normalized
}
pub(crate) fn normalize_api_format_json_object_keys(
value: Option<serde_json::Value>,
field_name: &str,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = normalize_json_like_object(value, field_name)? else {
return Ok(None);
};
let serde_json::Value::Object(map) = value else {
return Ok(Some(value));
};
let mut normalized = serde_json::Map::new();
for (key, value) in map {
let canonical = crate::ai_pipeline::normalize_api_format_alias(&key);
normalized.insert(canonical, value);
}
Ok(Some(serde_json::Value::Object(normalized)))
}
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
match auth_type.as_str() {
@@ -42,8 +74,8 @@ pub(crate) fn validate_vertex_api_formats(
}
let allowed = match auth_type {
"api_key" => &["gemini:chat"][..],
"service_account" | "vertex_ai" => &["claude:chat", "gemini:chat"][..],
"api_key" => &["gemini:generate_content"][..],
"service_account" | "vertex_ai" => &["claude:messages", "gemini:generate_content"][..],
_ => return Ok(()),
};
let invalid = api_formats
@@ -61,9 +93,26 @@ pub(crate) fn validate_vertex_api_formats(
))
}
fn normalize_json_like_object(
value: Option<serde_json::Value>,
field_name: &str,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = value else {
return Ok(None);
};
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::Object(map) => Ok(Some(serde_json::Value::Object(map))),
_ => Err(format!("{field_name} 必须是 JSON 对象")),
}
}
#[cfg(test)]
mod tests {
use super::{normalize_auth_type, normalize_pool_advanced_config};
use super::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
normalize_pool_advanced_config, validate_vertex_api_formats,
};
use serde_json::json;
#[test]
@@ -93,4 +142,60 @@ mod tests {
"bearer"
);
}
#[test]
fn normalize_api_format_list_dedupes_canonical_formats() {
assert_eq!(
normalize_api_format_list(vec![
"claude:messages".to_string(),
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
"openai:image".to_string(),
]),
vec![
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
"openai:image".to_string(),
]
);
}
#[test]
fn normalize_api_format_json_object_keys_keeps_canonical_keys() {
assert_eq!(
normalize_api_format_json_object_keys(
Some(json!({
"claude:messages": 2,
"gemini:generate_content": 3,
"openai:video": 4
})),
"rate_multipliers",
)
.expect("object should normalize"),
Some(json!({
"claude:messages": 2,
"gemini:generate_content": 3,
"openai:video": 4
}))
);
}
#[test]
fn validate_vertex_api_formats_uses_canonical_message_formats() {
assert!(validate_vertex_api_formats(
"vertex_ai",
"service_account",
&[
"claude:messages".to_string(),
"gemini:generate_content".to_string()
],
)
.is_ok());
assert!(validate_vertex_api_formats(
"vertex_ai",
"service_account",
&["claude:chat".to_string()],
)
.is_err());
}
}

View File

@@ -350,7 +350,7 @@ fn endpoint_matches_fixed_provider_template(
}
fn normalize_api_format_alias(value: &str) -> String {
crate::ai_pipeline::normalize_legacy_openai_format_alias(value)
crate::ai_pipeline::normalize_api_format_alias(value)
}
fn api_format_matches(left: &str, right: &str) -> bool {

View File

@@ -12,9 +12,41 @@ use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, StoredAdminProviderModel, UpsertAdminProviderModelRecord,
};
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
fn normalize_provider_model_mappings_api_formats(
value: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
let Some(mut value) = value else {
return None;
};
let Some(items) = value.as_array_mut() else {
return Some(value);
};
for item in items {
let Some(object) = item.as_object_mut() else {
continue;
};
let Some(api_formats) = object.get_mut("api_formats") else {
continue;
};
let Some(array) = api_formats.as_array() else {
continue;
};
let mut seen = BTreeSet::new();
let normalized = array
.iter()
.filter_map(serde_json::Value::as_str)
.map(crate::ai_pipeline::normalize_api_format_alias)
.filter(|format| seen.insert(format.clone()))
.map(serde_json::Value::String)
.collect::<Vec<_>>();
*api_formats = serde_json::Value::Array(normalized);
}
Some(value)
}
impl<'a> AdminAppState<'a> {
pub(crate) async fn admin_provider_model_name_exists(
&self,
@@ -105,8 +137,9 @@ impl<'a> AdminAppState<'a> {
"price_per_request",
)?;
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
let provider_model_mappings =
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?;
let provider_model_mappings = normalize_provider_model_mappings_api_formats(
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?,
);
let config = normalize_json_object(payload.config, "config")?;
admin_provider_models_write_pure::build_admin_provider_model_create_record(
Uuid::new_v4().to_string(),
@@ -190,7 +223,10 @@ impl<'a> AdminAppState<'a> {
existing.tiered_pricing.clone()
};
let provider_model_mappings = if fields.contains("provider_model_mappings") {
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
normalize_provider_model_mappings_api_formats(normalize_json_array(
payload.provider_model_mappings,
"provider_model_mappings",
)?)
} else {
existing.provider_model_mappings.clone()
};