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

View File

@@ -182,6 +182,7 @@ mod tests {
is_active: true,
api_formats: None,
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
@@ -240,6 +241,7 @@ mod tests {
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,

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ struct GatewayLocalCandidateResolutionPort<'a> {
auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&'a serde_json::Value>,
sticky_session_token: Option<&'a str>,
request_auth_channel: Option<&'a str>,
}
#[async_trait]
@@ -86,6 +87,11 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
transport: &Self::Transport,
requested_model: Option<&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(
transport,
candidate_transport_policy_facts(candidate),
@@ -169,6 +175,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
@@ -182,6 +189,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates(
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
AiCandidateResolutionMode::Standard,
)
.await
@@ -195,6 +203,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transpor
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
@@ -208,6 +217,7 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transpor
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
AiCandidateResolutionMode::WithoutTransportPairGate,
)
.await
@@ -221,6 +231,7 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
mode: AiCandidateResolutionMode,
) -> (
Vec<EligibleLocalExecutionCandidate>,
@@ -232,6 +243,7 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
};
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(
state: PlannerAppState<'_>,
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) auth_snapshot: GatewayAuthApiKeySnapshot,
pub(crate) required_capabilities: Option<serde_json::Value>,
pub(crate) request_auth_channel: Option<String>,
}
#[derive(Debug, Clone)]
@@ -93,6 +94,7 @@ pub(crate) fn build_local_requested_model_decision_input(
requested_model,
auth_snapshot: resolved_input.auth_snapshot,
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(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
Some(input)
}
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),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped

View File

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

View File

@@ -92,6 +92,7 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
None,
None,
persistence_policy,
candidates,
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(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
Some(input)
}
fn resolve_local_openai_image_auth_context(
@@ -155,6 +154,7 @@ async fn materialize_local_openai_image_candidate_attempts(
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
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(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
Some(input)
}
fn resolve_local_video_create_auth_context(
@@ -167,6 +166,7 @@ async fn materialize_local_video_create_candidate_attempts(
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped,

View File

@@ -69,10 +69,9 @@ pub(super) async fn resolve_local_standard_decision_input(
}
};
Some(build_local_requested_model_decision_input(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
Some(input)
}
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),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
preselection.candidates,
preselection.skipped_candidates,

View File

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

View File

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

View File

@@ -135,6 +135,7 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
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(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
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(
resolved_input,
requested_model,
))
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
input.request_auth_channel = decision.request_auth_channel.clone();
Some(input)
}
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),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
preselection.candidates,
preselection.skipped_candidates,