feat: configure auth channel mismatch formats

This commit is contained in:
fawney19
2026-05-03 00:49:22 +08:00
parent 3a770306cc
commit e3ea2d1451
63 changed files with 585 additions and 39 deletions

View File

@@ -69,6 +69,7 @@ struct GatewayLocalCandidateMaterializationPort<'a, F, G> {
auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&'a Value>, required_capabilities: Option<&'a Value>,
sticky_session_token: Option<&'a str>, sticky_session_token: Option<&'a str>,
request_auth_channel: Option<&'a str>,
persistence_policy: LocalCandidatePersistencePolicy<'a>, persistence_policy: LocalCandidatePersistencePolicy<'a>,
resolution_mode: LocalCandidateResolutionMode, resolution_mode: LocalCandidateResolutionMode,
build_available_extra_data: F, build_available_extra_data: F,
@@ -123,6 +124,7 @@ where
self.auth_snapshot, self.auth_snapshot,
self.required_capabilities, self.required_capabilities,
self.sticky_session_token, self.sticky_session_token,
self.request_auth_channel,
) )
.await .await
} }
@@ -135,6 +137,7 @@ where
self.auth_snapshot, self.auth_snapshot,
self.required_capabilities, self.required_capabilities,
self.sticky_session_token, self.sticky_session_token,
self.request_auth_channel,
) )
.await .await
} }
@@ -314,6 +317,7 @@ pub(crate) async fn materialize_local_execution_candidates_with_serving<F, G>(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&Value>, required_capabilities: Option<&Value>,
sticky_session_token: Option<&str>, sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
persistence_policy: LocalCandidatePersistencePolicy<'_>, persistence_policy: LocalCandidatePersistencePolicy<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>, candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
preselection_skipped: Vec<SkippedLocalExecutionCandidate>, preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
@@ -333,6 +337,7 @@ where
auth_snapshot, auth_snapshot,
required_capabilities, required_capabilities,
sticky_session_token, sticky_session_token,
request_auth_channel,
persistence_policy, persistence_policy,
resolution_mode, resolution_mode,
build_available_extra_data, build_available_extra_data,
@@ -682,6 +687,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["openai:chat".to_string()]), api_formats: Some(vec!["openai:chat".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -182,6 +182,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
@@ -240,6 +241,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -136,6 +136,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["openai:chat".to_string()]), api_formats: Some(vec!["openai:chat".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -1048,6 +1048,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1125,6 +1126,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1198,6 +1200,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1262,6 +1265,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1342,6 +1346,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1413,6 +1418,7 @@ mod tests {
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
None, None,
None,
) )
.await; .await;
@@ -1488,6 +1494,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await; .await;
@@ -1580,6 +1587,7 @@ mod tests {
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
None, None,
None,
) )
.await; .await;
@@ -1680,6 +1688,7 @@ mod tests {
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
None, None,
None,
) )
.await; .await;
@@ -1781,6 +1790,7 @@ mod tests {
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
None, None,
None,
) )
.await; .await;
@@ -1882,6 +1892,7 @@ mod tests {
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
None, None,
None,
) )
.await; .await;

View File

@@ -50,6 +50,7 @@ struct GatewayLocalCandidateResolutionPort<'a> {
auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&'a serde_json::Value>, required_capabilities: Option<&'a serde_json::Value>,
sticky_session_token: Option<&'a str>, sticky_session_token: Option<&'a str>,
request_auth_channel: Option<&'a str>,
} }
#[async_trait] #[async_trait]
@@ -86,6 +87,11 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
transport: &Self::Transport, transport: &Self::Transport,
requested_model: Option<&str>, requested_model: Option<&str>,
) -> Option<&'static str> { ) -> Option<&'static str> {
if let Some(skip_reason) =
candidate_auth_channel_skip_reason(transport, self.request_auth_channel)
{
return Some(skip_reason);
}
candidate_common_transport_skip_reason( candidate_common_transport_skip_reason(
transport, transport,
candidate_transport_policy_facts(candidate), candidate_transport_policy_facts(candidate),
@@ -169,6 +175,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>, required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>, sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
) -> ( ) -> (
Vec<EligibleLocalExecutionCandidate>, Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>, Vec<SkippedLocalExecutionCandidate>,
@@ -182,6 +189,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates(
auth_snapshot, auth_snapshot,
required_capabilities, required_capabilities,
sticky_session_token, sticky_session_token,
request_auth_channel,
AiCandidateResolutionMode::Standard, AiCandidateResolutionMode::Standard,
) )
.await .await
@@ -195,6 +203,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transpor
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>, required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>, sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
) -> ( ) -> (
Vec<EligibleLocalExecutionCandidate>, Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>, Vec<SkippedLocalExecutionCandidate>,
@@ -208,6 +217,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transpor
auth_snapshot, auth_snapshot,
required_capabilities, required_capabilities,
sticky_session_token, sticky_session_token,
request_auth_channel,
AiCandidateResolutionMode::WithoutTransportPairGate, AiCandidateResolutionMode::WithoutTransportPairGate,
) )
.await .await
@@ -221,6 +231,7 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>, required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>, sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
mode: AiCandidateResolutionMode, mode: AiCandidateResolutionMode,
) -> ( ) -> (
Vec<EligibleLocalExecutionCandidate>, Vec<EligibleLocalExecutionCandidate>,
@@ -232,6 +243,7 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
auth_snapshot, auth_snapshot,
required_capabilities, required_capabilities,
sticky_session_token, sticky_session_token,
request_auth_channel,
}; };
let request = AiCandidateResolutionRequest { let request = AiCandidateResolutionRequest {
@@ -257,6 +269,87 @@ fn candidate_transport_policy_facts(
} }
} }
fn candidate_auth_channel_skip_reason(
transport: &GatewayProviderTransportSnapshot,
request_auth_channel: Option<&str>,
) -> Option<&'static str> {
let request_auth_channel = normalize_request_auth_channel(request_auth_channel?)?;
let upstream_auth_channel = resolve_transport_request_auth_channel(transport)?;
if request_auth_channel == upstream_auth_channel
|| allow_auth_channel_mismatch_for_format(transport)
{
None
} else {
Some("auth_channel_mismatch")
}
}
fn normalize_request_auth_channel(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"api_key" | "api-key" | "apikey" => Some("api_key"),
"bearer_like" | "bearer-like" | "bearer" | "oauth" => Some("bearer_like"),
_ => None,
}
}
fn resolve_transport_request_auth_channel(
transport: &GatewayProviderTransportSnapshot,
) -> Option<&'static str> {
let auth_type = resolve_transport_auth_type_for_endpoint_format(transport);
match auth_type.as_str() {
"api_key" => Some("api_key"),
"bearer" => Some("bearer_like"),
"oauth" if provider_uses_bearer_like_oauth(&transport.provider.provider_type) => {
Some("bearer_like")
}
_ => None,
}
}
fn resolve_transport_auth_type_for_endpoint_format(
transport: &GatewayProviderTransportSnapshot,
) -> String {
let default_auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
let api_format = crate::ai_serving::normalize_api_format_alias(&transport.endpoint.api_format);
transport
.key
.auth_type_by_format
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|overrides| {
overrides
.get(&api_format)
.or_else(|| overrides.get(transport.endpoint.api_format.trim()))
})
.and_then(serde_json::Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "api_key" | "bearer"))
.unwrap_or(default_auth_type)
}
fn provider_uses_bearer_like_oauth(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "gemini_cli" | "antigravity" | "kiro"
)
}
fn allow_auth_channel_mismatch_for_format(transport: &GatewayProviderTransportSnapshot) -> bool {
let api_format = crate::ai_serving::normalize_api_format_alias(&transport.endpoint.api_format);
transport
.key
.allow_auth_channel_mismatch_formats
.as_ref()
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.any(|item| crate::ai_serving::normalize_api_format_alias(item) == api_format)
})
}
pub(crate) async fn read_candidate_transport_snapshot( pub(crate) async fn read_candidate_transport_snapshot(
state: PlannerAppState<'_>, state: PlannerAppState<'_>,
candidate: &SchedulerMinimalCandidateSelectionCandidate, candidate: &SchedulerMinimalCandidateSelectionCandidate,
@@ -285,3 +378,102 @@ pub(crate) async fn read_candidate_transport_snapshot(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::candidate_auth_channel_skip_reason;
use crate::ai_serving::GatewayProviderTransportSnapshot;
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider,
};
use serde_json::json;
fn sample_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "provider".to_string(),
provider_type: "custom".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "claude:messages".to_string(),
api_family: Some("claude".to_string()),
endpoint_kind: Some("messages".to_string()),
is_active: true,
base_url: "https://example.test".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: auth_type.to_string(),
is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "secret".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn auth_channel_gate_skips_mismatched_raw_secret_auth() {
let transport = sample_transport("bearer");
assert_eq!(
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
Some("auth_channel_mismatch")
);
}
#[test]
fn auth_channel_gate_allows_explicit_mismatch_format() {
let mut transport = sample_transport("bearer");
transport.key.allow_auth_channel_mismatch_formats = Some(json!(["claude:messages"]));
assert_eq!(
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
None
);
}
#[test]
fn auth_channel_gate_treats_cli_oauth_provider_as_bearer_like() {
let mut transport = sample_transport("oauth");
transport.provider.provider_type = "claude_code".to_string();
assert_eq!(
candidate_auth_channel_skip_reason(&transport, Some("bearer_like")),
None
);
assert_eq!(
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
Some("auth_channel_mismatch")
);
}
}

View File

@@ -18,6 +18,7 @@ pub(crate) struct LocalRequestedModelDecisionInput {
pub(crate) requested_model: String, pub(crate) requested_model: String,
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot, pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
pub(crate) required_capabilities: Option<serde_json::Value>, pub(crate) required_capabilities: Option<serde_json::Value>,
pub(crate) request_auth_channel: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -93,6 +94,7 @@ pub(crate) fn build_local_requested_model_decision_input(
requested_model, requested_model,
auth_snapshot: resolved_input.auth_snapshot, auth_snapshot: resolved_input.auth_snapshot,
required_capabilities: resolved_input.required_capabilities, required_capabilities: resolved_input.required_capabilities,
request_auth_channel: None,
} }
} }

View File

@@ -71,10 +71,9 @@ pub(crate) async fn resolve_local_same_format_provider_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }
pub(crate) async fn materialize_local_same_format_provider_candidate_attempts( pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
@@ -110,6 +109,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
candidates, candidates,
preselection_skipped preselection_skipped

View File

@@ -1232,6 +1232,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["openai:chat".to_string()]), api_formats: Some(vec!["openai:chat".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -92,6 +92,7 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
None, None,
None,
persistence_policy, persistence_policy,
candidates, candidates,
Vec::new(), Vec::new(),

View File

@@ -65,10 +65,9 @@ pub(super) async fn resolve_local_openai_image_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }
fn resolve_local_openai_image_auth_context( fn resolve_local_openai_image_auth_context(
@@ -155,6 +154,7 @@ async fn materialize_local_openai_image_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
candidates, candidates,
preselection_skipped, preselection_skipped,

View File

@@ -73,10 +73,9 @@ pub(super) async fn resolve_local_video_create_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }
fn resolve_local_video_create_auth_context( fn resolve_local_video_create_auth_context(
@@ -167,6 +166,7 @@ async fn materialize_local_video_create_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
candidates, candidates,
preselection_skipped, preselection_skipped,

View File

@@ -69,10 +69,9 @@ pub(super) async fn resolve_local_standard_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }
pub(super) async fn materialize_local_standard_candidate_attempts( pub(super) async fn materialize_local_standard_candidate_attempts(
@@ -109,6 +108,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
preselection.candidates, preselection.candidates,
preselection.skipped_candidates, preselection.skipped_candidates,

View File

@@ -338,6 +338,7 @@ mod tests {
requested_model: "claude-sonnet-4-5".to_string(), requested_model: "claude-sonnet-4-5".to_string(),
auth_snapshot: sample_auth_snapshot(), auth_snapshot: sample_auth_snapshot(),
required_capabilities: None, required_capabilities: None,
request_auth_channel: None,
} }
} }
@@ -398,6 +399,7 @@ mod tests {
"openai:chat".to_string(), "openai:chat".to_string(),
]), ]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -60,7 +60,7 @@ fn sample_transport(base_url: &str, api_format: &str) -> GatewayProviderTranspor
is_active: true, is_active: true,
api_formats: Some(vec![api_format.to_string()]), api_formats: Some(vec![api_format.to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -135,6 +135,7 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
candidates, candidates,
preselection_skipped, preselection_skipped,

View File

@@ -104,8 +104,7 @@ pub(crate) async fn resolve_local_openai_chat_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }

View File

@@ -122,10 +122,9 @@ pub(crate) async fn resolve_local_openai_responses_decision_input(
} }
}; };
Some(build_local_requested_model_decision_input( let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
resolved_input, input.request_auth_channel = decision.request_auth_channel.clone();
requested_model, Some(input)
))
} }
pub(crate) async fn materialize_local_openai_responses_candidate_attempts( pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
@@ -163,6 +162,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
sticky_session_token.as_deref(), sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy, persistence_policy,
preselection.candidates, preselection.candidates,
preselection.skipped_candidates, preselection.skipped_candidates,

View File

@@ -856,6 +856,10 @@ pub(super) fn build_admin_pool_key_payload(
"auth_type_by_format".to_string(), "auth_type_by_format".to_string(),
json!(key.auth_type_by_format), json!(key.auth_type_by_format),
); );
payload.insert(
"allow_auth_channel_mismatch_formats".to_string(),
json!(key.allow_auth_channel_mismatch_formats),
);
payload.insert( payload.insert(
"credential_kind".to_string(), "credential_kind".to_string(),
json!(auth_semantics.credential_kind().as_str()), json!(auth_semantics.credential_kind().as_str()),

View File

@@ -14,6 +14,8 @@ pub(crate) struct AdminProviderKeyCreateRequest {
#[serde(default)] #[serde(default)]
pub(crate) auth_type_by_format: Option<serde_json::Value>, pub(crate) auth_type_by_format: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
pub(crate) allow_auth_channel_mismatch_formats: Option<Option<Vec<String>>>,
#[serde(default)]
pub(crate) auth_config: Option<serde_json::Value>, pub(crate) auth_config: Option<serde_json::Value>,
pub(crate) name: String, pub(crate) name: String,
#[serde(default)] #[serde(default)]
@@ -53,6 +55,8 @@ pub(crate) struct AdminProviderKeyUpdateRequest {
#[serde(default)] #[serde(default)]
pub(crate) auth_type_by_format: Option<serde_json::Value>, pub(crate) auth_type_by_format: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
pub(crate) allow_auth_channel_mismatch_formats: Option<Vec<String>>,
#[serde(default)]
pub(crate) auth_config: Option<serde_json::Value>, pub(crate) auth_config: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
pub(crate) name: Option<String>, pub(crate) name: Option<String>,

View File

@@ -1,7 +1,8 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRequest; use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRequest;
use crate::handlers::admin::provider::write::normalize::{ use crate::handlers::admin::provider::write::normalize::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type, normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
normalize_auth_type_by_format, validate_vertex_api_formats, normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
validate_vertex_api_formats,
}; };
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{ use crate::handlers::admin::shared::{
@@ -192,6 +193,14 @@ pub(crate) async fn build_admin_create_provider_key_record(
key.health_by_format = Some(json!({})); key.health_by_format = Some(json!({}));
key.circuit_breaker_by_format = Some(json!({})); key.circuit_breaker_by_format = Some(json!({}));
key.auth_type_by_format = auth_type_by_format; key.auth_type_by_format = auth_type_by_format;
let allow_auth_channel_mismatch_formats = payload
.allow_auth_channel_mismatch_formats
.unwrap_or_else(|| Some(api_formats.clone()));
key.allow_auth_channel_mismatch_formats = normalize_allow_auth_channel_mismatch_formats(
allow_auth_channel_mismatch_formats,
"allow_auth_channel_mismatch_formats",
&api_formats,
)?;
key.created_at_unix_ms = Some(now_unix_secs); key.created_at_unix_ms = Some(now_unix_secs);
key.updated_at_unix_secs = Some(now_unix_secs); key.updated_at_unix_secs = Some(now_unix_secs);
Ok(key) Ok(key)

View File

@@ -1,7 +1,8 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch; use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::provider::write::normalize::{ use crate::handlers::admin::provider::write::normalize::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type, normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
normalize_auth_type_by_format, validate_vertex_api_formats, normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
validate_vertex_api_formats,
}; };
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{ use crate::handlers::admin::shared::{
@@ -221,6 +222,32 @@ pub(crate) async fn build_admin_update_provider_key_record(
} else { } else {
updated.auth_type_by_format = None; updated.auth_type_by_format = None;
} }
if fields.contains("allow_auth_channel_mismatch_formats") {
updated.allow_auth_channel_mismatch_formats =
normalize_allow_auth_channel_mismatch_formats(
payload.allow_auth_channel_mismatch_formats,
"allow_auth_channel_mismatch_formats",
&effective_api_formats,
)?;
} else if fields.contains("api_formats") {
let existing = updated
.allow_auth_channel_mismatch_formats
.as_ref()
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
});
updated.allow_auth_channel_mismatch_formats =
normalize_allow_auth_channel_mismatch_formats(
existing,
"allow_auth_channel_mismatch_formats",
&effective_api_formats,
)?;
}
updated.auth_type = target_auth_type; updated.auth_type = target_auth_type;

View File

@@ -81,6 +81,36 @@ pub(crate) fn normalize_auth_type_by_format(
} }
} }
pub(crate) fn normalize_allow_auth_channel_mismatch_formats(
values: Option<Vec<String>>,
field_name: &str,
api_formats: &[String],
) -> Result<Option<serde_json::Value>, String> {
let Some(values) = values else {
return Ok(None);
};
let allowed = api_formats.iter().cloned().collect::<BTreeSet<_>>();
let mut seen = BTreeSet::new();
let mut normalized = Vec::new();
for value in values {
let canonical = crate::ai_serving::normalize_api_format_alias(&value);
if canonical.is_empty() {
continue;
}
if !allowed.is_empty() && !allowed.contains(&canonical) {
return Err(format!("{field_name} 包含未选择的 API 格式: {canonical}"));
}
if seen.insert(canonical.clone()) {
normalized.push(serde_json::Value::String(canonical));
}
}
if normalized.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::Value::Array(normalized)))
}
}
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> { pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase(); let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
match auth_type.as_str() { match auth_type.as_str() {

View File

@@ -120,6 +120,17 @@ pub(crate) async fn build_admin_system_export_providers_payload(
internal_priority: Some(key.internal_priority), internal_priority: Some(key.internal_priority),
global_priority_by_format: key.global_priority_by_format.clone(), global_priority_by_format: key.global_priority_by_format.clone(),
auth_type_by_format: key.auth_type_by_format.clone(), auth_type_by_format: key.auth_type_by_format.clone(),
allow_auth_channel_mismatch_formats: key
.allow_auth_channel_mismatch_formats
.as_ref()
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
}),
rpm_limit: key.rpm_limit, rpm_limit: key.rpm_limit,
allowed_models: key.allowed_models.as_ref().and_then(|value| { allowed_models: key.allowed_models.as_ref().and_then(|value| {
value.as_array().map(|items| { value.as_array().map(|items| {

View File

@@ -1312,6 +1312,10 @@ pub(crate) fn build_admin_provider_key_response(
"auth_type_by_format".to_string(), "auth_type_by_format".to_string(),
json!(key.auth_type_by_format), json!(key.auth_type_by_format),
); );
payload.insert(
"allow_auth_channel_mismatch_formats".to_string(),
json!(key.allow_auth_channel_mismatch_formats),
);
payload.insert( payload.insert(
"credential_kind".to_string(), "credential_kind".to_string(),
json!(auth_semantics.credential_kind().as_str()), json!(auth_semantics.credential_kind().as_str()),

View File

@@ -760,7 +760,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec![api_format.to_string()]), api_formats: Some(vec![api_format.to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -173,6 +173,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -305,6 +305,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -1093,7 +1093,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_stream_via_local_decision_gate_wi
} }
fn sample_provider_catalog_key() -> StoredProviderCatalogKey { fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new( let mut key = StoredProviderCatalogKey::new(
"key-vertex-cli-stream-local-1".to_string(), "key-vertex-cli-stream-local-1".to_string(),
"provider-vertex-cli-stream-local-1".to_string(), "provider-vertex-cli-stream-local-1".to_string(),
"prod".to_string(), "prod".to_string(),
@@ -1114,7 +1114,10 @@ async fn gateway_executes_vertex_ai_gemini_cli_stream_via_local_decision_gate_wi
None, None,
None, None,
) )
.expect("key transport should build") .expect("key transport should build");
key.allow_auth_channel_mismatch_formats =
Some(serde_json::json!(["gemini:generate_content"]));
key
} }
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeStreamRequest>)); let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeStreamRequest>));

View File

@@ -1412,7 +1412,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_sync_via_local_decision_gate_with
} }
fn sample_provider_catalog_key() -> StoredProviderCatalogKey { fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new( let mut key = StoredProviderCatalogKey::new(
"key-vertex-cli-local-1".to_string(), "key-vertex-cli-local-1".to_string(),
"provider-vertex-cli-local-1".to_string(), "provider-vertex-cli-local-1".to_string(),
"prod".to_string(), "prod".to_string(),
@@ -1433,7 +1433,10 @@ async fn gateway_executes_vertex_ai_gemini_cli_sync_via_local_decision_gate_with
None, None,
None, None,
) )
.expect("key transport should build") .expect("key transport should build");
key.allow_auth_channel_mismatch_formats =
Some(serde_json::json!(["gemini:generate_content"]));
key
} }
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeSyncRequest>)); let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeSyncRequest>));

View File

@@ -214,6 +214,8 @@ pub struct AdminSystemConfigProviderKey {
#[serde(default)] #[serde(default)]
pub auth_type_by_format: Option<Value>, pub auth_type_by_format: Option<Value>,
#[serde(default)] #[serde(default)]
pub allow_auth_channel_mismatch_formats: Option<Vec<String>>,
#[serde(default)]
pub rpm_limit: Option<u32>, pub rpm_limit: Option<u32>,
#[serde(default)] #[serde(default)]
pub allowed_models: Option<Vec<String>>, pub allowed_models: Option<Vec<String>>,

View File

@@ -249,6 +249,7 @@ pub struct StoredProviderCatalogKey {
pub is_active: bool, pub is_active: bool,
pub api_formats: Option<serde_json::Value>, pub api_formats: Option<serde_json::Value>,
pub auth_type_by_format: Option<serde_json::Value>, pub auth_type_by_format: Option<serde_json::Value>,
pub allow_auth_channel_mismatch_formats: Option<serde_json::Value>,
pub encrypted_api_key: Option<String>, pub encrypted_api_key: Option<String>,
pub encrypted_auth_config: Option<String>, pub encrypted_auth_config: Option<String>,
pub note: Option<String>, pub note: Option<String>,
@@ -323,6 +324,7 @@ impl StoredProviderCatalogKey {
is_active, is_active,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
encrypted_api_key: None, encrypted_api_key: None,
encrypted_auth_config: None, encrypted_auth_config: None,
note: None, note: None,

View File

@@ -470,6 +470,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
note character varying(500), note character varying(500),
internal_priority integer DEFAULT 50, internal_priority integer DEFAULT 50,
rpm_limit integer, rpm_limit integer,
concurrent_limit integer,
allowed_models json, allowed_models json,
capabilities json, capabilities json,
learned_rpm_limit integer, learned_rpm_limit integer,
@@ -497,6 +498,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
provider_id character varying(36) NOT NULL, provider_id character varying(36) NOT NULL,
api_formats json, api_formats json,
auth_type_by_format json, auth_type_by_format json,
allow_auth_channel_mismatch_formats json,
rate_multipliers json, rate_multipliers json,
health_by_format jsonb, health_by_format jsonb,
circuit_breaker_by_format jsonb, circuit_breaker_by_format jsonb,

View File

@@ -0,0 +1,80 @@
ALTER TABLE public.provider_api_keys
ADD COLUMN IF NOT EXISTS allow_auth_channel_mismatch_formats json;
ALTER TABLE public.provider_api_keys
ADD COLUMN IF NOT EXISTS concurrent_limit integer;
CREATE OR REPLACE FUNCTION public.aether_default_auth_mismatch_api_format(value text)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE LOWER(BTRIM(COALESCE(value, '')))
WHEN 'openai:cli' THEN 'openai:responses'
WHEN 'openai:compact' THEN 'openai:responses:compact'
WHEN 'claude:chat' THEN 'claude:messages'
WHEN 'claude:cli' THEN 'claude:messages'
WHEN 'gemini:chat' THEN 'gemini:generate_content'
WHEN 'gemini:cli' THEN 'gemini:generate_content'
ELSE LOWER(BTRIM(COALESCE(value, '')))
END
$$;
WITH supported_formats AS (
SELECT
pak.id,
public.aether_default_auth_mismatch_api_format(format.value) AS api_format,
0 AS source_priority,
MIN(format.ordinality) AS first_ordinality
FROM public.provider_api_keys AS pak
CROSS JOIN LATERAL json_array_elements_text(
CASE
WHEN pak.api_formats IS NOT NULL
AND json_typeof(pak.api_formats) = 'array'
THEN pak.api_formats
ELSE '[]'::json
END
) WITH ORDINALITY AS format(value, ordinality)
WHERE pak.api_formats IS NOT NULL
AND json_typeof(pak.api_formats) = 'array'
GROUP BY pak.id, api_format
UNION ALL
SELECT
pak.id,
public.aether_default_auth_mismatch_api_format(endpoint.api_format) AS api_format,
1 AS source_priority,
0 AS first_ordinality
FROM public.provider_api_keys AS pak
INNER JOIN public.provider_endpoints AS endpoint
ON endpoint.provider_id = pak.provider_id
WHERE pak.api_formats IS NULL
OR json_typeof(pak.api_formats) <> 'array'
),
deduplicated_formats AS (
SELECT
id,
api_format,
MIN(source_priority) AS source_priority,
MIN(first_ordinality) AS first_ordinality
FROM supported_formats
WHERE api_format <> ''
GROUP BY id, api_format
),
rebuilt AS (
SELECT
id,
json_agg(api_format ORDER BY source_priority, first_ordinality, api_format) AS api_formats
FROM deduplicated_formats
GROUP BY id
)
UPDATE public.provider_api_keys AS pak
SET
allow_auth_channel_mismatch_formats = rebuilt.api_formats,
updated_at = NOW()
FROM rebuilt
WHERE pak.id = rebuilt.id
AND pak.allow_auth_channel_mismatch_formats IS NULL;
DROP FUNCTION public.aether_default_auth_mismatch_api_format(text);

View File

@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql"); static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260428000000; const BASELINE_V2_CUTOFF_VERSION: i64 = 20260502000000;
const MIGRATIONS_TABLE_EXISTS_SQL: &str = const MIGRATIONS_TABLE_EXISTS_SQL: &str =
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL"; "SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#" const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
@@ -665,6 +665,7 @@ SELECT EXISTS (
20260423000000, 20260423000000,
20260424000000, 20260424000000,
20260428000000, 20260428000000,
20260502000000,
] ]
); );
} }
@@ -732,7 +733,20 @@ SELECT EXISTS (
.sql .sql
.contains("api_formats json DEFAULT '[]'::json NOT NULL")); .contains("api_formats json DEFAULT '[]'::json NOT NULL"));
assert!(BASELINE_V2_SQL.contains("api_formats json,")); assert!(BASELINE_V2_SQL.contains("api_formats json,"));
assert!(BASELINE_V2_SQL.contains("concurrent_limit integer,"));
assert!(BASELINE_V2_SQL.contains("allow_auth_channel_mismatch_formats json,"));
assert!(!BASELINE_V2_SQL.contains("api_formats json DEFAULT '[]'::json NOT NULL")); assert!(!BASELINE_V2_SQL.contains("api_formats json DEFAULT '[]'::json NOT NULL"));
let auth_mismatch_migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260502000000)
.expect("auth mismatch migration should be embedded");
assert!(auth_mismatch_migration
.sql
.contains("allow_auth_channel_mismatch_formats = rebuilt.api_formats"));
assert!(auth_mismatch_migration
.sql
.contains("pak.allow_auth_channel_mismatch_formats IS NULL"));
} }
#[test] #[test]
@@ -1256,6 +1270,7 @@ ORDER BY id
20260423000000, 20260423000000,
20260424000000, 20260424000000,
20260428000000, 20260428000000,
20260502000000,
] ]
); );
} }

View File

@@ -141,6 +141,7 @@ SELECT
is_active, is_active,
api_formats, api_formats,
auth_type_by_format, auth_type_by_format,
allow_auth_channel_mismatch_formats,
api_key, api_key,
auth_config, auth_config,
note, note,
@@ -198,6 +199,7 @@ SELECT
is_active, is_active,
api_formats, api_formats,
auth_type_by_format, auth_type_by_format,
allow_auth_channel_mismatch_formats,
api_key, api_key,
auth_config, auth_config,
note, note,
@@ -1234,7 +1236,8 @@ INSERT INTO provider_api_keys (
circuit_breaker_by_format, circuit_breaker_by_format,
is_active, is_active,
created_at, created_at,
updated_at updated_at,
allow_auth_channel_mismatch_formats
) VALUES ( ) VALUES (
$1, $1,
$2, $2,
@@ -1310,7 +1313,8 @@ INSERT INTO provider_api_keys (
CASE CASE
WHEN $51::double precision IS NULL THEN NOW() WHEN $51::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($51::double precision) ELSE TO_TIMESTAMP($51::double precision)
END END,
$52
) )
"#, "#,
) )
@@ -1373,7 +1377,7 @@ INSERT INTO provider_api_keys (
.bind(key.is_active) .bind(key.is_active)
.bind(key.created_at_unix_ms.map(|value| value as f64)) .bind(key.created_at_unix_ms.map(|value| value as f64))
.bind(key.updated_at_unix_secs.map(|value| value as f64)) .bind(key.updated_at_unix_secs.map(|value| value as f64))
.bind(key.expires_at_unix_secs.map(|value| value as f64)) .bind(&key.allow_auth_channel_mismatch_formats)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_postgres_err()?; .map_postgres_err()?;
@@ -1723,6 +1727,7 @@ SET
provider_id = $2, provider_id = $2,
api_formats = $3, api_formats = $3,
auth_type_by_format = $39, auth_type_by_format = $39,
allow_auth_channel_mismatch_formats = $40,
auth_type = $4, auth_type = $4,
api_key = $5, api_key = $5,
auth_config = $6, auth_config = $6,
@@ -1818,6 +1823,7 @@ WHERE id = $1
.bind(key.updated_at_unix_secs.map(|value| value as f64)) .bind(key.updated_at_unix_secs.map(|value| value as f64))
.bind(key.expires_at_unix_secs.map(|value| value as f64)) .bind(key.expires_at_unix_secs.map(|value| value as f64))
.bind(&key.auth_type_by_format) .bind(&key.auth_type_by_format)
.bind(&key.allow_auth_channel_mismatch_formats)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_postgres_err()? .map_postgres_err()?
@@ -2479,6 +2485,8 @@ fn map_key_row(row: &PgRow) -> Result<StoredProviderCatalogKey, DataLayerError>
); );
key.note = row.try_get("note").ok(); key.note = row.try_get("note").ok();
key.auth_type_by_format = row.try_get("auth_type_by_format").ok(); key.auth_type_by_format = row.try_get("auth_type_by_format").ok();
key.allow_auth_channel_mismatch_formats =
row.try_get("allow_auth_channel_mismatch_formats").ok();
key.internal_priority = row.try_get("internal_priority").unwrap_or(50); key.internal_priority = row.try_get("internal_priority").unwrap_or(50);
key.cache_ttl_minutes = row.try_get("cache_ttl_minutes").unwrap_or(5); key.cache_ttl_minutes = row.try_get("cache_ttl_minutes").unwrap_or(5);
key.max_probe_interval_minutes = row.try_get("max_probe_interval_minutes").unwrap_or(32); key.max_probe_interval_minutes = row.try_get("max_probe_interval_minutes").unwrap_or(32);

View File

@@ -1173,6 +1173,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["gemini:generate_content".to_string()]), api_formats: Some(vec!["gemini:generate_content".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -588,6 +588,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec![api_format.to_string()]), api_formats: Some(vec![api_format.to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -361,6 +361,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -81,6 +81,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -120,6 +120,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -397,6 +397,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec![api_format.to_string()]), api_formats: Some(vec![api_format.to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -299,6 +299,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,
@@ -356,6 +357,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -177,6 +177,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -199,6 +199,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -137,6 +137,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -233,6 +233,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["claude:messages".to_string()]), api_formats: Some(vec!["claude:messages".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -299,6 +299,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -672,6 +672,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -116,6 +116,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -407,6 +407,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -403,6 +403,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -62,6 +62,7 @@ pub struct GatewayProviderTransportKey {
pub is_active: bool, pub is_active: bool,
pub api_formats: Option<Vec<String>>, pub api_formats: Option<Vec<String>>,
pub auth_type_by_format: Option<serde_json::Value>, pub auth_type_by_format: Option<serde_json::Value>,
pub allow_auth_channel_mismatch_formats: Option<serde_json::Value>,
pub allowed_models: Option<Vec<String>>, pub allowed_models: Option<Vec<String>>,
pub capabilities: Option<serde_json::Value>, pub capabilities: Option<serde_json::Value>,
pub rate_multipliers: Option<serde_json::Value>, pub rate_multipliers: Option<serde_json::Value>,
@@ -388,6 +389,7 @@ mod tests {
"openai:responses".to_string(), "openai:responses".to_string(),
]), ]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: Some(vec!["gpt-4.1".to_string(), "gpt-4.1-mini".to_string(),]), allowed_models: Some(vec!["gpt-4.1".to_string(), "gpt-4.1-mini".to_string(),]),
capabilities: Some(serde_json::json!({"cache_1h": true})), capabilities: Some(serde_json::json!({"cache_1h": true})),

View File

@@ -95,6 +95,9 @@ pub(super) fn map_key(
"provider_api_keys.api_formats", "provider_api_keys.api_formats",
)?, )?,
auth_type_by_format: normalize_optional_json(key.auth_type_by_format), auth_type_by_format: normalize_optional_json(key.auth_type_by_format),
allow_auth_channel_mismatch_formats: normalize_optional_json(
key.allow_auth_channel_mismatch_formats,
),
allowed_models: normalize_string_list( allowed_models: normalize_string_list(
normalize_optional_json(key.allowed_models), normalize_optional_json(key.allowed_models),
"provider_api_keys.allowed_models", "provider_api_keys.allowed_models",

View File

@@ -283,6 +283,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,
rate_multipliers: None, rate_multipliers: None,

View File

@@ -89,6 +89,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["gemini:generate_content".to_string()]), api_formats: Some(vec!["gemini:generate_content".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -112,6 +112,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["gemini:generate_content".to_string()]), api_formats: Some(vec!["gemini:generate_content".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -204,6 +204,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: Some(vec!["gemini:generate_content".to_string()]), api_formats: Some(vec!["gemini:generate_content".to_string()]),
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -297,6 +297,7 @@ mod tests {
is_active: true, is_active: true,
api_formats: None, api_formats: None,
auth_type_by_format: None, auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None, allowed_models: None,
capabilities: None, capabilities: None,

View File

@@ -174,6 +174,7 @@ export interface ProviderKeyExport {
internal_priority?: number internal_priority?: number
global_priority_by_format?: Record<string, number> | null global_priority_by_format?: Record<string, number> | null
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
rpm_limit?: number | null rpm_limit?: number | null
allowed_models?: string[] | null allowed_models?: string[] | null
capabilities?: Record<string, boolean> capabilities?: Record<string, boolean>

View File

@@ -140,6 +140,7 @@ export async function addProviderKey(
api_key: string api_key: string
auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型 auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
name: string name: string
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
@@ -169,6 +170,7 @@ export async function updateProviderKey(
api_key: string api_key: string
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型 auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats: string[] | null
auth_config: Record<string, unknown> // 认证配置Vertex AI Service Account JSON auth_config: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
name: string name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率

View File

@@ -105,6 +105,7 @@ export interface PoolKeyDetail {
is_active: boolean is_active: boolean
auth_type: string auth_type: string
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean
@@ -212,6 +213,7 @@ export interface PoolKeySelectionItem {
key_name: string key_name: string
auth_type: string auth_type: string
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean

View File

@@ -231,6 +231,7 @@ export interface EndpointAPIKey {
api_key_plain?: string | null api_key_plain?: string | null
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型(必返回) auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型(必返回)
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean oauth_managed?: boolean
@@ -388,6 +389,7 @@ export interface EndpointAPIKeyUpdate {
api_key?: string // 仅在需要更新时提供 api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型 auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
allow_auth_channel_mismatch_formats?: string[] | null
auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number internal_priority?: number

View File

@@ -163,6 +163,18 @@
{{ opt.label }} {{ opt.label }}
</button> </button>
</div> </div>
<div
v-if="canToggleAuthChannelMismatch(format)"
class="flex items-center gap-1"
title="允许客户端认证方式不一致时使用"
@click.stop
>
<Switch
:model-value="isAuthChannelMismatchAllowed(format)"
class="scale-75"
@update:model-value="(value) => setAuthChannelMismatchAllowed(format, value)"
/>
</div>
<div <div
class="flex items-center text-xs text-muted-foreground gap-1" class="flex items-center text-xs text-muted-foreground gap-1"
@click.stop @click.stop
@@ -478,6 +490,25 @@ function sanitizeAuthTypeByFormat(
return sanitized return sanitized
} }
function sanitizeAllowAuthChannelMismatchFormats(
formats: string[] | null | undefined,
selectedFormats = form.value.api_formats
): string[] {
if (!formats) return []
const selected = new Set(selectedFormats.map(normalizeApiFormat))
const seen = new Set<string>()
const sanitized: string[] = []
for (const format of formats) {
const normalizedFormat = normalizeApiFormat(format)
if (!normalizedFormat || !selected.has(normalizedFormat) || seen.has(normalizedFormat)) {
continue
}
seen.add(normalizedFormat)
sanitized.push(normalizedFormat)
}
return sanitized
}
function getDefaultApiFormats(): string[] { function getDefaultApiFormats(): string[] {
const endpointFormat = props.endpoint?.api_format const endpointFormat = props.endpoint?.api_format
if (endpointFormat) { if (endpointFormat) {
@@ -549,12 +580,35 @@ function setFormatAuthType(format: string, authType: RawSecretAuthType) {
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(next) form.value.auth_type_by_format = sanitizeAuthTypeByFormat(next)
} }
function canToggleAuthChannelMismatch(format: string): boolean {
return canOverrideFormatAuth(format)
}
function isAuthChannelMismatchAllowed(format: string): boolean {
return form.value.allow_auth_channel_mismatch_formats.includes(normalizeApiFormat(format))
}
function setAuthChannelMismatchAllowed(format: string, allowed: boolean) {
const normalizedFormat = normalizeApiFormat(format)
const next = new Set(form.value.allow_auth_channel_mismatch_formats.map(normalizeApiFormat))
if (allowed) {
next.add(normalizedFormat)
} else {
next.delete(normalizedFormat)
}
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats([...next])
}
function buildAuthTypeByFormatPayload(): Record<string, RawSecretAuthType> | null { function buildAuthTypeByFormatPayload(): Record<string, RawSecretAuthType> | null {
const sanitized = sanitizeAuthTypeByFormat(form.value.auth_type_by_format) const sanitized = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
return Object.keys(sanitized).length > 0 ? sanitized : null return Object.keys(sanitized).length > 0 ? sanitized : null
} }
function buildAllowAuthChannelMismatchFormatsPayload(): string[] {
const sanitized = sanitizeAllowAuthChannelMismatchFormats(form.value.allow_auth_channel_mismatch_formats)
return sanitized
}
const serviceAccountDescription = computed(() => ( const serviceAccountDescription = computed(() => (
props.editingKey props.editingKey
? '留空表示不修改JSON 格式,包含 project_id、private_key 等字段' ? '留空表示不修改JSON 格式,包含 project_id、private_key 等字段'
@@ -566,6 +620,10 @@ function getDefaultAuthType(): ProviderKeyFormAuthType {
return authTypeOptions.value[0]?.value || 'api_key' return authTypeOptions.value[0]?.value || 'api_key'
} }
function getDefaultAllowAuthChannelMismatchFormats(formats = getDefaultApiFormats()): string[] {
return sanitizeAllowAuthChannelMismatchFormats(formats, formats)
}
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models // 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => { const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false if (!props.editingKey) return false
@@ -632,6 +690,7 @@ const form = ref({
api_key: '', // 标准 API Key api_key: '', // 标准 API Key
auth_type: 'api_key' as ProviderKeyFormAuthType, // 认证类型 auth_type: 'api_key' as ProviderKeyFormAuthType, // 认证类型
auth_type_by_format: {} as Record<string, RawSecretAuthType>, auth_type_by_format: {} as Record<string, RawSecretAuthType>,
allow_auth_channel_mismatch_formats: [] as string[],
auth_config_text: '', // Service Account JSON 文本(用于表单输入) auth_config_text: '', // Service Account JSON 文本(用于表单输入)
api_formats: [] as string[], // 支持的 API 格式列表 api_formats: [] as string[], // 支持的 API 格式列表
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率 rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
@@ -661,6 +720,9 @@ watch(
form.value.api_formats = [...filtered] form.value.api_formats = [...filtered]
} }
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format) form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats(
form.value.allow_auth_channel_mismatch_formats
)
}, },
{ immediate: true } { immediate: true }
) )
@@ -676,6 +738,10 @@ watch(
if (filtered.length !== form.value.api_formats.length) { if (filtered.length !== form.value.api_formats.length) {
form.value.api_formats = [...filtered] form.value.api_formats = [...filtered]
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format, filtered) form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format, filtered)
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats(
form.value.allow_auth_channel_mismatch_formats,
filtered
)
return return
} }
@@ -683,6 +749,8 @@ watch(
const defaults = getDefaultApiFormats() const defaults = getDefaultApiFormats()
if (defaults.length > 0) { if (defaults.length > 0) {
form.value.api_formats = defaults form.value.api_formats = defaults
form.value.allow_auth_channel_mismatch_formats =
getDefaultAllowAuthChannelMismatchFormats(defaults)
} }
} }
}, },
@@ -708,10 +776,14 @@ function toggleApiFormat(format: string) {
if (index === -1) { if (index === -1) {
// 添加格式 // 添加格式
form.value.api_formats.push(format) form.value.api_formats.push(format)
setAuthChannelMismatchAllowed(format, true)
} else { } else {
// 移除格式,但保留倍率配置(用户可能只是临时取消) // 移除格式,但保留倍率配置(用户可能只是临时取消)
form.value.api_formats.splice(index, 1) form.value.api_formats.splice(index, 1)
} }
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats(
form.value.allow_auth_channel_mismatch_formats
)
} }
// 更新指定格式的成本倍率 // 更新指定格式的成本倍率
@@ -738,13 +810,16 @@ function updateRateMultiplier(format: string, value: string | number) {
// 重置表单 // 重置表单
function resetForm() { function resetForm() {
formNonce.value = createFieldNonce() formNonce.value = createFieldNonce()
const defaultApiFormats = getDefaultApiFormats()
form.value = { form.value = {
name: '', name: '',
api_key: '', api_key: '',
auth_type: getDefaultAuthType(), auth_type: getDefaultAuthType(),
auth_type_by_format: {}, auth_type_by_format: {},
allow_auth_channel_mismatch_formats:
getDefaultAllowAuthChannelMismatchFormats(defaultApiFormats),
auth_config_text: '', auth_config_text: '',
api_formats: getDefaultApiFormats(), api_formats: defaultApiFormats,
rate_multipliers: {}, rate_multipliers: {},
internal_priority: 10, internal_priority: 10,
rpm_limit: undefined, rpm_limit: undefined,
@@ -766,6 +841,9 @@ function clearForNextAdd() {
form.value.api_key = '' form.value.api_key = ''
form.value.auth_config_text = '' form.value.auth_config_text = ''
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format) form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats(
form.value.allow_auth_channel_mismatch_formats
)
} }
// 加载密钥数据(编辑模式) // 加载密钥数据(编辑模式)
@@ -781,6 +859,10 @@ function loadKeyData() {
props.editingKey.api_formats || [], props.editingKey.api_formats || [],
normalizeFormAuthType(props.editingKey.auth_type) normalizeFormAuthType(props.editingKey.auth_type)
), ),
allow_auth_channel_mismatch_formats: sanitizeAllowAuthChannelMismatchFormats(
props.editingKey.allow_auth_channel_mismatch_formats || [],
props.editingKey.api_formats || []
),
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入 auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
api_formats: props.editingKey.api_formats?.length > 0 api_formats: props.editingKey.api_formats?.length > 0
? sanitizeApiFormats( ? sanitizeApiFormats(
@@ -873,6 +955,9 @@ async function handleSave() {
} }
form.value.api_formats = sanitizeApiFormats(form.value.api_formats) form.value.api_formats = sanitizeApiFormats(form.value.api_formats)
form.value.allow_auth_channel_mismatch_formats = sanitizeAllowAuthChannelMismatchFormats(
form.value.allow_auth_channel_mismatch_formats
)
// 验证至少选择一个 API 格式 // 验证至少选择一个 API 格式
if (form.value.api_formats.length === 0) { if (form.value.api_formats.length === 0) {
@@ -905,6 +990,7 @@ async function handleSave() {
// 准备认证相关数据 // 准备认证相关数据
const authConfig = parseAuthConfig() const authConfig = parseAuthConfig()
const authTypeByFormat = buildAuthTypeByFormatPayload() const authTypeByFormat = buildAuthTypeByFormatPayload()
const allowAuthChannelMismatchFormats = buildAllowAuthChannelMismatchFormatsPayload()
if (props.editingKey) { if (props.editingKey) {
const shouldClearAllowedModels = !!props.editingKey.auto_fetch_models && !form.value.auto_fetch_models const shouldClearAllowedModels = !!props.editingKey.auto_fetch_models && !form.value.auto_fetch_models
@@ -916,6 +1002,7 @@ async function handleSave() {
name: form.value.name, name: form.value.name,
auth_type: form.value.auth_type, auth_type: form.value.auth_type,
auth_type_by_format: authTypeByFormat, auth_type_by_format: authTypeByFormat,
allow_auth_channel_mismatch_formats: allowAuthChannelMismatchFormats,
rate_multipliers: rateMultipliersData, rate_multipliers: rateMultipliersData,
internal_priority: form.value.internal_priority, internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit, rpm_limit: form.value.rpm_limit,
@@ -947,6 +1034,7 @@ async function handleSave() {
api_key: form.value.api_key, api_key: form.value.api_key,
auth_type: form.value.auth_type, auth_type: form.value.auth_type,
auth_type_by_format: authTypeByFormat, auth_type_by_format: authTypeByFormat,
allow_auth_channel_mismatch_formats: allowAuthChannelMismatchFormats,
auth_config: authConfig || undefined, auth_config: authConfig || undefined,
name: form.value.name, name: form.value.name,
rate_multipliers: rateMultipliersData, rate_multipliers: rateMultipliersData,