mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(pool): 引入 pro_first 调度预设、Pool 候选持久化跳过与诊断信息优化
- 新增 pro_first 调度预设(Pro 优先),更新 plus_first 仅针对 Plus 计划,移除 free_team_first - Pool 内部候选(pool_key_index 不为空)跳过 DB 持久化(available/skipped/unused 均适用) - LRU 排序新增 catalog_lru_score 回退:runtime 无记录时使用 last_used_at_unix_secs - 执行路径 miss 诊断消息细化为中文,按 reason 分类输出可读说明 - build_local_request_candidate_status_record 补充 extra_data 和 created_at_unix_ms 字段 - OpenAI CLI 计划构建流程补充候选评估进度跟踪与 terminal reason 设置 - 前端 PoolSchedulingDialog 增加 pro_first 预设展示,修复 LRU 默认预设检测逻辑
This commit is contained in:
@@ -9,6 +9,7 @@ use crate::ai_pipeline::planner::candidate_eligibility::{
|
||||
use crate::ai_pipeline::planner::runtime_miss::record_local_runtime_candidate_skip_reason;
|
||||
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::clock::current_unix_ms;
|
||||
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
|
||||
use crate::orchestration::{local_attempt_slot_count, ExecutionAttemptIdentity};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -67,6 +68,16 @@ pub(crate) fn remember_first_local_candidate_affinity(
|
||||
);
|
||||
}
|
||||
|
||||
fn should_persist_available_local_candidate(eligible: &EligibleLocalExecutionCandidate) -> bool {
|
||||
eligible.orchestration.pool_key_index.is_none()
|
||||
}
|
||||
|
||||
fn should_persist_skipped_local_candidate(candidate: &SkippedLocalExecutionCandidate) -> bool {
|
||||
candidate.transport.as_ref().is_none_or(|transport| {
|
||||
admin_provider_pool_config_from_config_value(transport.provider.config.as_ref()).is_none()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn persist_available_local_execution_candidates<F>(
|
||||
state: PlannerAppState<'_>,
|
||||
@@ -102,21 +113,25 @@ where
|
||||
let attempt_identity = ExecutionAttemptIdentity::new(candidate_index, retry_index)
|
||||
.with_pool_key_index(pool_key_index);
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
let candidate_id = state
|
||||
.persist_available_local_candidate(
|
||||
trace_id,
|
||||
user_id,
|
||||
api_key_id,
|
||||
&eligible.candidate,
|
||||
attempt_identity.candidate_index,
|
||||
attempt_identity.retry_index,
|
||||
&generated_candidate_id,
|
||||
required_capabilities,
|
||||
extra_data.clone(),
|
||||
created_at_unix_ms,
|
||||
error_context,
|
||||
)
|
||||
.await;
|
||||
let candidate_id = if should_persist_available_local_candidate(eligible) {
|
||||
state
|
||||
.persist_available_local_candidate(
|
||||
trace_id,
|
||||
user_id,
|
||||
api_key_id,
|
||||
&eligible.candidate,
|
||||
attempt_identity.candidate_index,
|
||||
attempt_identity.retry_index,
|
||||
&generated_candidate_id,
|
||||
required_capabilities,
|
||||
extra_data.clone(),
|
||||
created_at_unix_ms,
|
||||
error_context,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
generated_candidate_id
|
||||
};
|
||||
|
||||
let eligible = if retry_index + 1 == attempt_slots {
|
||||
owned_eligible
|
||||
@@ -235,7 +250,11 @@ pub(crate) async fn persist_skipped_local_execution_candidates(
|
||||
error_context: &'static str,
|
||||
record_runtime_miss_diagnostic: bool,
|
||||
) {
|
||||
for (skipped_offset, skipped_candidate) in skipped_candidates.into_iter().enumerate() {
|
||||
let mut next_candidate_index = starting_candidate_index;
|
||||
for skipped_candidate in skipped_candidates {
|
||||
if !should_persist_skipped_local_candidate(&skipped_candidate) {
|
||||
continue;
|
||||
}
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
persist_skipped_local_execution_candidate(
|
||||
state,
|
||||
@@ -243,7 +262,7 @@ pub(crate) async fn persist_skipped_local_execution_candidates(
|
||||
user_id,
|
||||
api_key_id,
|
||||
&skipped_candidate.candidate,
|
||||
starting_candidate_index + skipped_offset as u32,
|
||||
next_candidate_index,
|
||||
&generated_candidate_id,
|
||||
required_capabilities,
|
||||
skipped_candidate.skip_reason,
|
||||
@@ -252,6 +271,7 @@ pub(crate) async fn persist_skipped_local_execution_candidates(
|
||||
record_runtime_miss_diagnostic,
|
||||
)
|
||||
.await;
|
||||
next_candidate_index = next_candidate_index.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,3 +295,200 @@ pub(crate) async fn persist_skipped_local_execution_candidates_with_context(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::orchestration::LocalExecutionCandidateMetadata;
|
||||
|
||||
fn sample_candidate(key_id: &str) -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "provider-1".to_string(),
|
||||
provider_type: "codex".to_string(),
|
||||
provider_priority: 10,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
key_id: key_id.to_string(),
|
||||
key_name: key_id.to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_internal_priority: 10,
|
||||
key_global_priority_for_format: Some(10),
|
||||
key_capabilities: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gpt-5".to_string(),
|
||||
selected_provider_model_name: "gpt-5".to_string(),
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_transport(
|
||||
key_id: &str,
|
||||
provider_config: Option<serde_json::Value>,
|
||||
) -> Arc<crate::ai_pipeline::GatewayProviderTransportSnapshot> {
|
||||
Arc::new(crate::ai_pipeline::GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "provider-1".to_string(),
|
||||
provider_type: "codex".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: provider_config,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://example.com".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_id.to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: key_id.to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_eligible(
|
||||
key_id: &str,
|
||||
pool_key_index: Option<u32>,
|
||||
) -> EligibleLocalExecutionCandidate {
|
||||
EligibleLocalExecutionCandidate {
|
||||
candidate: sample_candidate(key_id),
|
||||
transport: sample_transport(
|
||||
key_id,
|
||||
pool_key_index.map(|_| json!({ "pool_advanced": {} })),
|
||||
),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
orchestration: LocalExecutionCandidateMetadata {
|
||||
candidate_group_id: pool_key_index.map(|_| "pool-group".to_string()),
|
||||
pool_key_index,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_candidates_are_not_persisted_as_available_before_attempt() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let app = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_request_candidate_repository_for_tests(Arc::clone(
|
||||
&repository,
|
||||
)),
|
||||
);
|
||||
|
||||
let attempts = persist_available_local_execution_candidates(
|
||||
PlannerAppState::new(&app),
|
||||
"trace-pool-lazy",
|
||||
"user-1",
|
||||
"api-key-1",
|
||||
None,
|
||||
vec![
|
||||
sample_eligible("pool-key", Some(0)),
|
||||
sample_eligible("normal-key", None),
|
||||
],
|
||||
"persist should not fail",
|
||||
|_| None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(attempts.len(), 2);
|
||||
let stored = app
|
||||
.read_request_candidates_by_request_id("trace-pool-lazy")
|
||||
.await
|
||||
.expect("request candidates should read");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].key_id.as_deref(), Some("normal-key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_internal_skipped_candidates_are_not_persisted() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let app = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_request_candidate_repository_for_tests(Arc::clone(
|
||||
&repository,
|
||||
)),
|
||||
);
|
||||
|
||||
persist_skipped_local_execution_candidates(
|
||||
&app,
|
||||
"trace-pool-skipped",
|
||||
"user-1",
|
||||
"api-key-1",
|
||||
None,
|
||||
0,
|
||||
vec![
|
||||
SkippedLocalExecutionCandidate {
|
||||
candidate: sample_candidate("pool-skipped"),
|
||||
skip_reason: "pool_cooldown",
|
||||
transport: Some(sample_transport(
|
||||
"pool-skipped",
|
||||
Some(json!({ "pool_advanced": {} })),
|
||||
)),
|
||||
extra_data: None,
|
||||
},
|
||||
SkippedLocalExecutionCandidate {
|
||||
candidate: sample_candidate("normal-skipped"),
|
||||
skip_reason: "key_inactive",
|
||||
transport: None,
|
||||
extra_data: None,
|
||||
},
|
||||
],
|
||||
"persist skipped should not fail",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let stored = app
|
||||
.read_request_candidates_by_request_id("trace-pool-skipped")
|
||||
.await
|
||||
.expect("request candidates should read");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].key_id.as_deref(), Some("normal-skipped"));
|
||||
assert_eq!(stored[0].candidate_index, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ struct PoolCatalogKeyContext {
|
||||
quota_exhausted: bool,
|
||||
health_score: Option<f64>,
|
||||
latency_avg_ms: Option<f64>,
|
||||
catalog_lru_score: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -219,6 +220,7 @@ fn build_pool_catalog_key_context(
|
||||
.unwrap_or(false),
|
||||
health_score,
|
||||
latency_avg_ms,
|
||||
catalog_lru_score: Some(key.last_used_at_unix_secs.unwrap_or(0) as f64),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +484,9 @@ fn schedule_pool_group(
|
||||
continue;
|
||||
}
|
||||
|
||||
let lru_score =
|
||||
runtime_lru_score(runtime, key_id.as_str()).or(key_context.catalog_lru_score);
|
||||
|
||||
available.push(PoolGroupCandidateOrdering {
|
||||
eligible: EligibleLocalExecutionCandidate {
|
||||
candidate,
|
||||
@@ -491,7 +496,7 @@ fn schedule_pool_group(
|
||||
},
|
||||
key_context,
|
||||
original_index,
|
||||
lru_score: runtime_lru_score(runtime, key_id.as_str()),
|
||||
lru_score,
|
||||
cost_usage: runtime_cost_usage(runtime, key_id.as_str()),
|
||||
});
|
||||
}
|
||||
@@ -593,8 +598,8 @@ fn build_pool_sort_vectors(
|
||||
"cache_affinity" => cache_affinity_ranks.clone(),
|
||||
"priority_first" => priority_first_ranks(items, &lru_ranks),
|
||||
"single_account" => single_account_ranks(items),
|
||||
"free_team_first" => plan_ranks(items, &lru_ranks, preset.mode.as_deref()),
|
||||
"plus_first" => plan_ranks(items, &lru_ranks, Some("plus_only")),
|
||||
"pro_first" => plan_ranks(items, &lru_ranks, Some("pro_only")),
|
||||
"free_first" => plan_ranks(items, &lru_ranks, Some("free_only")),
|
||||
"team_first" => plan_ranks(items, &lru_ranks, Some("team_only")),
|
||||
"health_first" => health_first_ranks(items, &lru_ranks),
|
||||
@@ -906,8 +911,17 @@ fn plan_priority_score(plan_type: Option<&str>, mode: Option<&str>) -> f64 {
|
||||
None => 0.8,
|
||||
},
|
||||
"plus_only" => match plan_type {
|
||||
Some("plus" | "pro") => 0.0,
|
||||
Some("enterprise" | "business") => 0.3,
|
||||
Some("plus") => 0.0,
|
||||
Some("pro") => 0.3,
|
||||
Some("enterprise" | "business") => 0.4,
|
||||
Some("free" | "team") => 0.7,
|
||||
Some(_) => 0.7,
|
||||
None => 0.8,
|
||||
},
|
||||
"pro_only" => match plan_type {
|
||||
Some("pro") => 0.0,
|
||||
Some("plus") => 0.3,
|
||||
Some("enterprise" | "business") => 0.4,
|
||||
Some("free" | "team") => 0.7,
|
||||
Some(_) => 0.7,
|
||||
None => 0.8,
|
||||
@@ -992,7 +1006,7 @@ fn normalize_enabled_pool_presets(
|
||||
|
||||
fn pool_preset_supported_for_provider(preset: &str, provider_type: &str) -> bool {
|
||||
match preset {
|
||||
"free_first" | "free_team_first" | "plus_first" | "recent_refresh" | "team_first" => {
|
||||
"free_first" | "plus_first" | "pro_first" | "recent_refresh" | "team_first" => {
|
||||
matches!(provider_type, "codex" | "kiro")
|
||||
}
|
||||
_ => true,
|
||||
@@ -1090,6 +1104,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_scheduler_uses_catalog_last_used_when_runtime_lru_is_missing() {
|
||||
let recent_key = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
"key-recent",
|
||||
10,
|
||||
Some(json!({ "pool_advanced": { "lru_enabled": true } })),
|
||||
);
|
||||
let older_key = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
"key-older",
|
||||
10,
|
||||
Some(json!({ "pool_advanced": { "lru_enabled": true } })),
|
||||
);
|
||||
|
||||
let key_context_by_id = BTreeMap::from([
|
||||
(
|
||||
"key-recent".to_string(),
|
||||
PoolCatalogKeyContext {
|
||||
catalog_lru_score: Some(200.0),
|
||||
..PoolCatalogKeyContext::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"key-older".to_string(),
|
||||
PoolCatalogKeyContext {
|
||||
catalog_lru_score: Some(100.0),
|
||||
..PoolCatalogKeyContext::default()
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let (reordered, skipped) = apply_local_execution_pool_scheduler_with_runtime_map(
|
||||
vec![recent_key, older_key],
|
||||
&BTreeMap::new(),
|
||||
&key_context_by_id,
|
||||
);
|
||||
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(
|
||||
reordered
|
||||
.iter()
|
||||
.map(|item| item.candidate.key_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["key-older", "key-recent"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_scheduler_attaches_group_and_pool_metadata_to_ranked_candidates() {
|
||||
let pool_first = sample_eligible_candidate(
|
||||
@@ -1394,7 +1458,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_scheduler_supports_free_team_first_modes() {
|
||||
fn pool_scheduler_supports_pro_first_plan_preset() {
|
||||
let key_plus = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
@@ -1402,18 +1466,18 @@ mod tests {
|
||||
10,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [{"preset": "free_team_first", "enabled": true, "mode": "team_only"}]
|
||||
"scheduling_presets": [{"preset": "pro_first", "enabled": true}]
|
||||
}
|
||||
})),
|
||||
);
|
||||
let key_free = sample_eligible_candidate(
|
||||
let key_pro = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
"key-free",
|
||||
"key-pro",
|
||||
10,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [{"preset": "free_team_first", "enabled": true, "mode": "team_only"}]
|
||||
"scheduling_presets": [{"preset": "pro_first", "enabled": true}]
|
||||
}
|
||||
})),
|
||||
);
|
||||
@@ -1424,7 +1488,7 @@ mod tests {
|
||||
10,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [{"preset": "free_team_first", "enabled": true, "mode": "team_only"}]
|
||||
"scheduling_presets": [{"preset": "pro_first", "enabled": true}]
|
||||
}
|
||||
})),
|
||||
);
|
||||
@@ -1438,9 +1502,9 @@ mod tests {
|
||||
},
|
||||
),
|
||||
(
|
||||
"key-free".to_string(),
|
||||
"key-pro".to_string(),
|
||||
PoolCatalogKeyContext {
|
||||
oauth_plan_type: Some("free".to_string()),
|
||||
oauth_plan_type: Some("pro".to_string()),
|
||||
..PoolCatalogKeyContext::default()
|
||||
},
|
||||
),
|
||||
@@ -1454,7 +1518,7 @@ mod tests {
|
||||
]);
|
||||
|
||||
let (reordered, skipped) = apply_local_execution_pool_scheduler_with_runtime_map(
|
||||
vec![key_plus, key_free, key_team],
|
||||
vec![key_plus, key_team, key_pro],
|
||||
&BTreeMap::new(),
|
||||
&key_context_by_id,
|
||||
);
|
||||
@@ -1465,7 +1529,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|item| item.candidate.key_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["key-team", "key-free", "key-plus"]
|
||||
vec!["key-pro", "key-plus", "key-team"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1584,6 +1648,7 @@ mod tests {
|
||||
}));
|
||||
key.success_count = Some(4);
|
||||
key.total_response_time_ms = Some(200);
|
||||
key.last_used_at_unix_secs = Some(1_711_000_123);
|
||||
|
||||
let app = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1601,6 +1666,7 @@ mod tests {
|
||||
assert_eq!(context.quota_usage_ratio, Some(0.25));
|
||||
assert_eq!(context.quota_reset_seconds, Some(3600.0));
|
||||
assert_eq!(context.latency_avg_ms, Some(50.0));
|
||||
assert_eq!(context.catalog_lru_score, Some(1_711_000_123.0));
|
||||
}
|
||||
|
||||
fn sample_eligible_candidate(
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::ai_pipeline::planner::decision_input::{
|
||||
use crate::ai_pipeline::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_pipeline::planner::runtime_miss::set_local_runtime_miss_diagnostic_reason;
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_openai_cli_spec_metadata;
|
||||
use crate::ai_pipeline::PlannerAppState;
|
||||
use crate::ai_pipeline::{
|
||||
@@ -47,28 +48,83 @@ pub(crate) async fn resolve_local_openai_cli_decision_input(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Option<LocalOpenAiCliDecisionInput> {
|
||||
let auth_context: ExecutionRuntimeAuthContext =
|
||||
resolve_local_decision_execution_runtime_auth_context(decision)?;
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
route_class = ?decision.route_class,
|
||||
route_family = ?decision.route_family,
|
||||
route_kind = ?decision.route_kind,
|
||||
"gateway local openai cli decision skipped: missing_auth_context"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
extract_standard_requested_model(body_json).as_deref(),
|
||||
"missing_auth_context",
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let requested_model = extract_standard_requested_model(body_json)?;
|
||||
let Some(requested_model) = extract_standard_requested_model(body_json) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
"gateway local openai cli decision skipped: missing_requested_model"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
None,
|
||||
"missing_requested_model",
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
auth_context.clone(),
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => return None,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
user_id = %auth_context.user_id,
|
||||
api_key_id = %auth_context.api_key_id,
|
||||
"gateway local openai cli decision skipped: auth_snapshot_missing"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_missing",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai cli decision auth snapshot read failed"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_read_failed",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -85,7 +141,7 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
input: &LocalOpenAiCliDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalOpenAiCliSpec,
|
||||
) -> Result<Vec<LocalOpenAiCliCandidateAttempt>, GatewayError> {
|
||||
) -> Result<(Vec<LocalOpenAiCliCandidateAttempt>, usize), GatewayError> {
|
||||
let spec_metadata = local_openai_cli_spec_metadata(spec);
|
||||
let client_api_format = spec_metadata.api_format.trim().to_ascii_lowercase();
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
@@ -223,6 +279,8 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let candidate_count = candidates.len() + skipped_candidates.len();
|
||||
|
||||
remember_first_local_candidate_affinity(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
@@ -275,7 +333,7 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(attempts)
|
||||
Ok((attempts, candidate_count))
|
||||
}
|
||||
pub(crate) async fn mark_skipped_local_openai_cli_candidate(
|
||||
state: &AppState,
|
||||
|
||||
@@ -60,12 +60,13 @@ pub(crate) async fn maybe_build_sync_local_openai_cli_decision_payload(
|
||||
};
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json, plan_kind)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let attempts =
|
||||
let (attempts, _) =
|
||||
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
|
||||
@@ -95,12 +96,13 @@ pub(crate) async fn maybe_build_stream_local_openai_cli_decision_payload(
|
||||
};
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json, plan_kind)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let attempts =
|
||||
let (attempts, _) =
|
||||
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ use crate::ai_pipeline::planner::plan_builders::{
|
||||
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
|
||||
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::runtime_miss::{
|
||||
apply_local_runtime_candidate_evaluation_progress,
|
||||
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_openai_cli_spec_metadata;
|
||||
use crate::ai_pipeline::GatewayControlDecision;
|
||||
pub(crate) use crate::ai_pipeline::{
|
||||
@@ -26,15 +30,33 @@ pub(super) async fn build_local_sync_plan_and_reports(
|
||||
spec: LocalOpenAiCliSpec,
|
||||
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
|
||||
let spec_metadata = local_openai_cli_spec_metadata(spec);
|
||||
let Some(input) =
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
|
||||
let Some(input) = resolve_local_openai_cli_decision_input(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
|
||||
let attempts =
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
@@ -60,6 +82,7 @@ pub(super) async fn build_local_sync_plan_and_reports(
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
@@ -72,15 +95,33 @@ pub(super) async fn build_local_stream_plan_and_reports(
|
||||
spec: LocalOpenAiCliSpec,
|
||||
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
|
||||
let spec_metadata = local_openai_cli_spec_metadata(spec);
|
||||
let Some(input) =
|
||||
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
|
||||
let Some(input) = resolve_local_openai_cli_decision_input(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
|
||||
let attempts =
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
@@ -106,5 +147,6 @@ pub(super) async fn build_local_stream_plan_and_reports(
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::{execute_execution_runtime_stream, execute_execution_runtime_sync};
|
||||
use crate::executor::{build_local_execution_exhaustion, LocalExecutionRequestOutcome};
|
||||
use crate::log_ids::short_request_id;
|
||||
use crate::orchestration::local_execution_candidate_metadata_from_report_context;
|
||||
use crate::request_candidate_runtime::{
|
||||
record_local_request_candidate_status, RequestCandidateRuntimeWriter,
|
||||
};
|
||||
@@ -246,10 +247,14 @@ where
|
||||
T: LocalPlanAndReport,
|
||||
{
|
||||
for plan_and_report in remaining {
|
||||
let report_context = plan_and_report.report_context();
|
||||
if should_skip_unused_persistence(report_context.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
record_local_request_candidate_status(
|
||||
state,
|
||||
plan_and_report.plan(),
|
||||
plan_and_report.report_context().as_ref(),
|
||||
report_context.as_ref(),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Unused,
|
||||
status_code: None,
|
||||
@@ -264,6 +269,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_unused_persistence(report_context: Option<&serde_json::Value>) -> bool {
|
||||
let metadata = local_execution_candidate_metadata_from_report_context(report_context);
|
||||
metadata.candidate_group_id.is_some() && metadata.pool_key_index.is_some()
|
||||
}
|
||||
|
||||
fn resolve_stream_candidate_watchdog_timeout(plan: &aether_contracts::ExecutionPlan) -> Duration {
|
||||
let timeout_ms = plan
|
||||
.timeouts
|
||||
@@ -352,10 +362,14 @@ pub(crate) async fn mark_unused_local_candidate_items<T, FPlan, FContext>(
|
||||
FContext: Fn(&T) -> Option<&serde_json::Value>,
|
||||
{
|
||||
for item in remaining {
|
||||
let report_context = report_context(&item);
|
||||
if should_skip_unused_persistence(report_context) {
|
||||
continue;
|
||||
}
|
||||
record_local_request_candidate_status(
|
||||
state,
|
||||
plan(&item),
|
||||
report_context(&item),
|
||||
report_context,
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Unused,
|
||||
status_code: None,
|
||||
@@ -464,6 +478,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_persistence_skips_pool_internal_candidates() {
|
||||
assert!(should_skip_unused_persistence(Some(&json!({
|
||||
"candidate_group_id": "pool-group",
|
||||
"pool_key_index": 1,
|
||||
}))));
|
||||
assert!(!should_skip_unused_persistence(Some(&json!({
|
||||
"candidate_group_id": "pool-group",
|
||||
}))));
|
||||
assert!(!should_skip_unused_persistence(Some(&json!({
|
||||
"candidate_index": 1,
|
||||
}))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_candidate_watchdog_marks_failed_candidate_and_continues() {
|
||||
let writer = Arc::new(TestRequestCandidateWriter::default());
|
||||
|
||||
@@ -9,10 +9,10 @@ const POOL_ALLOWED_SCHEDULING_PRESETS: &[&str] = &[
|
||||
"load_balance",
|
||||
"single_account",
|
||||
"priority_first",
|
||||
"free_team_first",
|
||||
"free_first",
|
||||
"team_first",
|
||||
"plus_first",
|
||||
"pro_first",
|
||||
"health_first",
|
||||
"latency_first",
|
||||
"cost_first",
|
||||
@@ -28,12 +28,12 @@ fn json_u64(value: &Value) -> Option<u64> {
|
||||
|
||||
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<String> {
|
||||
match preset {
|
||||
"free_team_first" | "free_first" | "team_first" | "plus_first" => {
|
||||
"free_first" | "team_first" | "plus_first" | "pro_first" => {
|
||||
let default_mode = match preset {
|
||||
"free_team_first" => "both",
|
||||
"free_first" => "free_only",
|
||||
"team_first" => "team_only",
|
||||
"plus_first" => "plus_only",
|
||||
"pro_first" => "pro_only",
|
||||
_ => unreachable!("preset covered by outer match"),
|
||||
};
|
||||
let normalized = raw_mode
|
||||
@@ -42,12 +42,10 @@ fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.filter(|value| match preset {
|
||||
"free_team_first" => {
|
||||
matches!(value.as_str(), "both" | "free_only" | "team_only")
|
||||
}
|
||||
"free_first" => value == "free_only",
|
||||
"team_first" => value == "team_only",
|
||||
"plus_first" => value == "plus_only",
|
||||
"pro_first" => value == "pro_only",
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or_else(|| default_mode.to_string());
|
||||
@@ -426,14 +424,15 @@ mod tests {
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [
|
||||
{"preset": "cache_affinity", "enabled": false},
|
||||
{"preset": "plus_first", "enabled": true, "mode": "plus_only"}
|
||||
{"preset": "plus_first", "enabled": true, "mode": "plus_only"},
|
||||
{"preset": "pro_first", "enabled": true, "mode": "pro_only"}
|
||||
]
|
||||
}
|
||||
})))
|
||||
.expect("pool config should parse");
|
||||
|
||||
assert!(!config.lru_enabled);
|
||||
assert_eq!(config.scheduling_presets.len(), 2);
|
||||
assert_eq!(config.scheduling_presets.len(), 3);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "cache_affinity");
|
||||
assert!(!config.scheduling_presets[0].enabled);
|
||||
assert_eq!(config.scheduling_presets[1].preset, "plus_first");
|
||||
@@ -441,17 +440,22 @@ mod tests {
|
||||
config.scheduling_presets[1].mode.as_deref(),
|
||||
Some("plus_only")
|
||||
);
|
||||
assert_eq!(config.scheduling_presets[2].preset, "pro_first");
|
||||
assert_eq!(
|
||||
config.scheduling_presets[2].mode.as_deref(),
|
||||
Some("pro_only")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_string_style_scheduling_presets_like_python() {
|
||||
fn parses_legacy_string_style_scheduling_presets() {
|
||||
let config = admin_provider_pool_config_from_config_value(Some(&json!({
|
||||
"pool_advanced": {
|
||||
"lru_enabled": false,
|
||||
"scheduling_presets": [
|
||||
"free_team_first",
|
||||
"free_first",
|
||||
"recent_refresh",
|
||||
"free_team_first"
|
||||
"free_first"
|
||||
]
|
||||
}
|
||||
})))
|
||||
@@ -461,24 +465,24 @@ mod tests {
|
||||
assert_eq!(config.scheduling_presets.len(), 3);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "lru");
|
||||
assert!(!config.scheduling_presets[0].enabled);
|
||||
assert_eq!(config.scheduling_presets[1].preset, "free_team_first");
|
||||
assert_eq!(config.scheduling_presets[1].preset, "free_first");
|
||||
assert_eq!(config.scheduling_presets[2].preset, "recent_refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_free_team_first_mode_defaults_to_both() {
|
||||
fn retired_free_team_first_preset_is_rejected() {
|
||||
let config = admin_provider_pool_config_from_config_value(Some(&json!({
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [
|
||||
{"preset": "free_team_first", "enabled": true, "mode": "invalid_mode"}
|
||||
{"preset": "free_team_first", "enabled": true, "mode": "team_only"}
|
||||
]
|
||||
}
|
||||
})))
|
||||
.expect("pool config should parse");
|
||||
|
||||
assert_eq!(config.scheduling_presets.len(), 1);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "free_team_first");
|
||||
assert_eq!(config.scheduling_presets[0].mode.as_deref(), Some("both"));
|
||||
assert_eq!(config.scheduling_presets[0].preset, "lru");
|
||||
assert_eq!(config.scheduling_presets[0].mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,19 +62,19 @@ use std::{collections::BTreeMap, time::Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const OPENAI_CHAT_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Chat Completions 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_RESPONSES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI responses execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Responses 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_COMPACT_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI compact execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Responses Compact 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_VIDEO_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const CLAUDE_MESSAGES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Claude messages execution runtime miss did not match a Rust execution path";
|
||||
"当前 Claude Messages 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const GEMINI_PUBLIC_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Gemini public execution runtime miss did not match a Rust execution path";
|
||||
"当前 Gemini Public 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const GEMINI_FILES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Gemini files execution runtime miss did not match a Rust execution path";
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const LOCAL_ROUTE_NOT_FOUND_DETAIL: &str = "Route not found";
|
||||
const LOCAL_PROXY_PASSTHROUGH_REMOVED_DETAIL: &str =
|
||||
"Route matched a removed compatibility passthrough; implement it in Rust or retire the route";
|
||||
@@ -1258,9 +1258,7 @@ pub(crate) async fn proxy_request(
|
||||
auth_api_key_concurrency_limited,
|
||||
stream_request,
|
||||
)
|
||||
.unwrap_or_else(|| {
|
||||
"AI public execution runtime miss did not match a Rust execution path".to_string()
|
||||
});
|
||||
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
|
||||
let local_execution_failure_path = if auth_api_key_concurrency_limited {
|
||||
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED
|
||||
} else {
|
||||
@@ -1424,34 +1422,237 @@ fn local_execution_runtime_miss_detail(
|
||||
return Some(AUTH_API_KEY_CONCURRENCY_LIMIT_REACHED_DETAIL.to_string());
|
||||
}
|
||||
|
||||
if let Some(detail) = local_execution_runtime_miss_model_detail(diagnostic, stream_request) {
|
||||
if let Some(detail) =
|
||||
local_execution_runtime_miss_diagnostic_detail(decision, diagnostic, stream_request)
|
||||
{
|
||||
return Some(detail);
|
||||
}
|
||||
|
||||
local_execution_runtime_miss_route_detail(decision).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_model_detail(
|
||||
fn local_execution_runtime_miss_diagnostic_detail(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
stream_request: bool,
|
||||
) -> Option<String> {
|
||||
let diagnostic = diagnostic?;
|
||||
if !matches!(
|
||||
diagnostic.reason.as_str(),
|
||||
"candidate_list_empty" | "all_candidates_skipped"
|
||||
) {
|
||||
return None;
|
||||
let route_label = local_execution_runtime_miss_route_label(decision);
|
||||
let request_mode = local_execution_runtime_miss_request_mode(stream_request);
|
||||
|
||||
match diagnostic.reason.as_str() {
|
||||
"candidate_list_empty" => {
|
||||
return Some(local_execution_runtime_miss_candidate_list_empty_detail(
|
||||
diagnostic,
|
||||
request_mode,
|
||||
));
|
||||
}
|
||||
"all_candidates_skipped" => {
|
||||
return Some(local_execution_runtime_miss_all_candidates_skipped_detail(
|
||||
diagnostic,
|
||||
request_mode,
|
||||
));
|
||||
}
|
||||
"missing_auth_context" => {
|
||||
return Some(format!(
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商({route_label},原因代码: missing_auth_context)"
|
||||
));
|
||||
}
|
||||
"missing_requested_model" => {
|
||||
return Some(format!(
|
||||
"请求缺少 model 字段,无法选择上游提供商({route_label},原因代码: missing_requested_model)"
|
||||
));
|
||||
}
|
||||
"auth_snapshot_missing" => {
|
||||
return Some(format!(
|
||||
"当前 API Key 的本地执行配置不存在或已过期,无法选择上游提供商({route_label},原因代码: auth_snapshot_missing)"
|
||||
));
|
||||
}
|
||||
"auth_snapshot_read_failed" => {
|
||||
return Some(format!(
|
||||
"读取 API Key 的本地执行配置失败,无法选择上游提供商({route_label},原因代码: auth_snapshot_read_failed)"
|
||||
));
|
||||
}
|
||||
"decision_input_unavailable" => {
|
||||
return Some(format!(
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商({route_label},原因代码: decision_input_unavailable)"
|
||||
));
|
||||
}
|
||||
"execution_runtime_candidates_exhausted" => {
|
||||
return Some(format!(
|
||||
"已尝试所有本地执行候选提供商,但没有任何候选成功完成请求({route_label},原因代码: execution_runtime_candidates_exhausted)"
|
||||
));
|
||||
}
|
||||
"candidate_evaluation_incomplete" => {
|
||||
return Some(format!(
|
||||
"本地执行候选评估未完成,暂时无法为本次{request_mode}请求选择上游提供商({route_label},原因代码: candidate_evaluation_incomplete)"
|
||||
));
|
||||
}
|
||||
"no_local_sync_plans" | "no_local_stream_plans" => {
|
||||
return Some(format!(
|
||||
"找到了候选提供商,但无法为本次{request_mode}请求构建本地执行计划。请检查端点路径、认证方式、Header/Body 规则和格式转换配置({route_label},原因代码: {})",
|
||||
diagnostic.reason
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let requested_model = diagnostic
|
||||
let reason = diagnostic.reason.trim();
|
||||
if reason.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"当前请求无法在本地执行:{route_label} 的执行路径未就绪(原因代码: {reason})"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_candidate_list_empty_detail(
|
||||
diagnostic: &LocalExecutionRuntimeMissDiagnostic,
|
||||
request_mode: &str,
|
||||
) -> String {
|
||||
if let Some(requested_model) = diagnostic_requested_model(diagnostic) {
|
||||
return format!(
|
||||
"没有可用提供商支持模型 {requested_model} 的{request_mode}请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"没有可用提供商支持本次{request_mode}请求。请检查模型字段、模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_all_candidates_skipped_detail(
|
||||
diagnostic: &LocalExecutionRuntimeMissDiagnostic,
|
||||
request_mode: &str,
|
||||
) -> String {
|
||||
let candidate_count = diagnostic.candidate_count.unwrap_or(0);
|
||||
let skipped_count = diagnostic
|
||||
.skipped_candidate_count
|
||||
.unwrap_or(candidate_count);
|
||||
let skipped_summary =
|
||||
local_execution_runtime_miss_skip_reasons_summary(&diagnostic.skip_reasons);
|
||||
let requested_model = diagnostic_requested_model(diagnostic);
|
||||
|
||||
match (candidate_count, skipped_summary, requested_model) {
|
||||
(count, Some(summary), Some(model)) if count > 0 => format!(
|
||||
"找到 {count} 个支持模型 {model} 的候选提供商,但本次{request_mode}请求全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, Some(summary), None) if count > 0 => format!(
|
||||
"找到 {count} 个候选提供商,但本次{request_mode}请求全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, Some(summary), Some(model)) => format!(
|
||||
"支持模型 {model} 的候选提供商全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, Some(summary), None) => format!(
|
||||
"候选提供商全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, None, Some(model)) if count > 0 => format!(
|
||||
"找到 {count} 个支持模型 {model} 的候选提供商,但都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, None, None) if count > 0 => format!(
|
||||
"找到 {count} 个候选提供商,但都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, None, Some(model)) if skipped_count > 0 => format!(
|
||||
"支持模型 {model} 的 {skipped_count} 个候选提供商都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
_ => format!(
|
||||
"候选提供商都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_requested_model(diagnostic: &LocalExecutionRuntimeMissDiagnostic) -> Option<&str> {
|
||||
diagnostic
|
||||
.requested_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let request_mode = if stream_request { "流式" } else { "同步" };
|
||||
Some(format!(
|
||||
"没有可用的提供商支持模型 {requested_model} 的{request_mode}请求"
|
||||
))
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_skip_reasons_summary(
|
||||
skip_reasons: &BTreeMap<String, usize>,
|
||||
) -> Option<String> {
|
||||
if skip_reasons.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
skip_reasons
|
||||
.iter()
|
||||
.map(|(reason, count)| {
|
||||
format!(
|
||||
"{} {} 次",
|
||||
local_execution_runtime_miss_skip_reason_label(reason),
|
||||
count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_skip_reason_label(reason: &str) -> &str {
|
||||
match reason {
|
||||
"api_key_concurrency_limit_reached" => "API Key 并发已达上限",
|
||||
"auth_snapshot_missing" => "API Key 本地执行配置缺失",
|
||||
"endpoint_api_format_changed" => "端点 API 格式已变更",
|
||||
"endpoint_inactive" => "端点未启用",
|
||||
"key_api_format_disabled" => "API Key 未启用该 API 格式",
|
||||
"key_inactive" => "API Key 未启用",
|
||||
"key_model_disabled" => "API Key 未允许该模型",
|
||||
"mapped_model_missing" => "模型映射缺失",
|
||||
"provider_inactive" => "提供商未启用",
|
||||
"provider_request_body_missing" => "无法构建上游请求体",
|
||||
"transport_api_format_mismatch" => "传输层 API 格式不匹配",
|
||||
"transport_api_format_unsupported" => "传输层不支持该 API 格式",
|
||||
"transport_auth_unavailable" => "上游认证信息不可用",
|
||||
"transport_body_rules_unsupported" => "Body 规则不支持本地执行",
|
||||
"transport_custom_path_unsupported" => "自定义路径不支持本地执行",
|
||||
"transport_header_rules_unsupported" => "Header 规则不支持本地执行",
|
||||
"transport_header_rules_apply_failed" => "Header 规则应用失败",
|
||||
"transport_oauth_resolution_unsupported" => "OAuth 认证解析不支持本地执行",
|
||||
"transport_provider_type_unsupported" => "提供商类型不支持本地执行",
|
||||
"transport_proxy_or_tls_unsupported" => "代理或 TLS 配置不支持本地执行",
|
||||
"transport_proxy_unsupported" => "代理配置不支持本地执行",
|
||||
"transport_snapshot_missing" => "提供商传输配置缺失",
|
||||
"transport_tls_profile_unsupported" => "TLS 指纹配置不支持本地执行",
|
||||
"transport_unsupported" => "传输配置不支持本地执行",
|
||||
"upstream_url_missing" => "无法构建上游请求地址",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_request_mode(stream_request: bool) -> &'static str {
|
||||
if stream_request {
|
||||
"流式"
|
||||
} else {
|
||||
"同步"
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_route_label(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
) -> &'static str {
|
||||
let Some(decision) = decision else {
|
||||
return "AI 请求";
|
||||
};
|
||||
match decision.public_path.as_str() {
|
||||
"/v1/chat/completions" => "OpenAI Chat Completions",
|
||||
"/v1/responses" => "OpenAI Responses",
|
||||
"/v1/responses/compact" => "OpenAI Responses Compact",
|
||||
"/v1/messages" => "Claude Messages",
|
||||
path if path.starts_with("/v1/videos") => "OpenAI Video",
|
||||
path if path.starts_with("/upload/v1beta/files") || path.starts_with("/v1beta/files") => {
|
||||
"Gemini Files"
|
||||
}
|
||||
path if decision.route_family.as_deref() == Some("gemini")
|
||||
&& (path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/")) =>
|
||||
{
|
||||
"Gemini Public"
|
||||
}
|
||||
_ => "AI 请求",
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_is_auth_api_key_concurrency_limited(
|
||||
@@ -1526,12 +1727,14 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("没有可用的提供商支持模型 gpt-5.4 的流式请求")
|
||||
Some(
|
||||
"没有可用提供商支持模型 gpt-5.4 的流式请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_miss_detail_falls_back_to_route_default_when_reason_is_not_model_unavailable() {
|
||||
fn runtime_miss_detail_returns_auth_context_message_when_auth_context_is_missing() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/messages",
|
||||
Some("ai_public".to_string()),
|
||||
@@ -1550,7 +1753,9 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("Claude messages execution runtime miss did not match a Rust execution path")
|
||||
Some(
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(Claude Messages,原因代码: missing_auth_context)"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ async fn gateway_locally_denies_sync_ai_control_execute_when_opted_in_and_execut
|
||||
);
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -255,7 +255,7 @@ async fn gateway_locally_denies_stream_ai_control_execute_when_opted_in_and_exec
|
||||
);
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -393,7 +393,7 @@ async fn gateway_does_not_proxy_control_execute_over_http_when_opted_in_and_exec
|
||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -526,7 +526,7 @@ async fn gateway_does_not_proxy_control_execute_over_http_when_opted_in_and_exec
|
||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -122,7 +122,7 @@ async fn gateway_locally_denies_openai_chat_after_repeated_execution_runtime_mis
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ async fn gateway_locally_denies_openai_chat_when_control_api_is_configured_witho
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -359,7 +359,7 @@ async fn gateway_locally_denies_openai_chat_stream_after_execution_runtime_miss_
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path"
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Chat Completions,原因代码: missing_auth_context)"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -537,7 +537,7 @@ async fn gateway_locally_denies_openai_responses_after_execution_runtime_miss_wi
|
||||
"cli",
|
||||
"openai:cli",
|
||||
"{\"model\":\"gpt-5\",\"input\":\"hello\"}",
|
||||
"OpenAI responses execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Responses,原因代码: missing_auth_context)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -551,7 +551,7 @@ async fn gateway_locally_denies_claude_messages_after_execution_runtime_miss_wit
|
||||
"chat",
|
||||
"claude:chat",
|
||||
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[]}",
|
||||
"Claude messages execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商(Claude Messages,原因代码: decision_input_unavailable)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -565,7 +565,7 @@ async fn gateway_locally_denies_openai_responses_stream_after_execution_runtime_
|
||||
"cli",
|
||||
"openai:cli",
|
||||
"{\"model\":\"gpt-5\",\"input\":\"hello\",\"stream\":true}",
|
||||
"OpenAI responses execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Responses,原因代码: missing_auth_context)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -579,7 +579,7 @@ async fn gateway_locally_denies_claude_messages_stream_after_execution_runtime_m
|
||||
"chat",
|
||||
"claude:chat",
|
||||
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[],\"stream\":true}",
|
||||
"Claude messages execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商(Claude Messages,原因代码: decision_input_unavailable)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -593,7 +593,7 @@ async fn gateway_locally_denies_openai_compact_after_execution_runtime_miss_with
|
||||
"compact",
|
||||
"openai:compact",
|
||||
"{\"model\":\"gpt-5\",\"input\":\"hello\"}",
|
||||
"OpenAI compact execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Responses Compact,原因代码: missing_auth_context)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -607,7 +607,7 @@ async fn gateway_locally_denies_openai_compact_stream_after_execution_runtime_mi
|
||||
"compact",
|
||||
"openai:compact",
|
||||
"{\"model\":\"gpt-5\",\"input\":\"hello\",\"stream\":true}",
|
||||
"OpenAI compact execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(OpenAI Responses Compact,原因代码: missing_auth_context)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -621,7 +621,7 @@ async fn gateway_locally_denies_gemini_generate_after_execution_runtime_miss_wit
|
||||
"chat",
|
||||
"gemini:chat",
|
||||
"{\"contents\":[]}",
|
||||
"Gemini public execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商(Gemini Public,原因代码: decision_input_unavailable)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -635,7 +635,7 @@ async fn gateway_locally_denies_gemini_v1_generate_after_execution_runtime_miss_
|
||||
"chat",
|
||||
"gemini:chat",
|
||||
"{\"contents\":[]}",
|
||||
"Gemini public execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商(Gemini Public,原因代码: decision_input_unavailable)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -649,7 +649,7 @@ async fn gateway_locally_denies_gemini_stream_after_execution_runtime_miss_witho
|
||||
"chat",
|
||||
"gemini:chat",
|
||||
"{\"contents\":[]}",
|
||||
"Gemini public execution runtime miss did not match a Rust execution path",
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商(Gemini Public,原因代码: decision_input_unavailable)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -663,7 +663,7 @@ async fn gateway_locally_denies_openai_video_after_execution_runtime_miss_withou
|
||||
"video",
|
||||
"openai:video",
|
||||
"{\"model\":\"sora-2\"}",
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path",
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -677,7 +677,7 @@ async fn gateway_locally_denies_gemini_video_after_execution_runtime_miss_withou
|
||||
"video",
|
||||
"gemini:video",
|
||||
"{\"instances\":[]}",
|
||||
"Gemini public execution runtime miss did not match a Rust execution path",
|
||||
"当前 Gemini Public 请求无法在本地执行:没有匹配到可用的执行路径",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -693,7 +693,7 @@ async fn gateway_locally_denies_gemini_files_root_after_execution_runtime_miss_w
|
||||
"files",
|
||||
"gemini:chat",
|
||||
None,
|
||||
"Gemini files execution runtime miss did not match a Rust execution path",
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -709,7 +709,7 @@ async fn gateway_locally_denies_gemini_files_download_after_execution_runtime_mi
|
||||
"files",
|
||||
"gemini:chat",
|
||||
None,
|
||||
"Gemini files execution runtime miss did not match a Rust execution path",
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -725,7 +725,7 @@ async fn gateway_locally_denies_gemini_files_upload_after_execution_runtime_miss
|
||||
"files",
|
||||
"gemini:chat",
|
||||
Some("{\"file\":{}}"),
|
||||
"Gemini files execution runtime miss did not match a Rust execution path",
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -654,7 +654,7 @@ async fn gateway_surfaces_local_execution_runtime_miss_reason_when_all_openai_ch
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"没有可用的提供商支持模型 gpt-5 的同步请求"
|
||||
"找到 1 个支持模型 gpt-5 的候选提供商,但本次同步请求全部不可用:提供商类型不支持本地执行 2 次(原因代码: all_candidates_skipped)"
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
|
||||
@@ -562,7 +562,7 @@ async fn gateway_surfaces_candidate_list_empty_reason_for_claude_chat_runtime_mi
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"没有可用的提供商支持模型 claude-sonnet-4-5 的同步请求"
|
||||
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
|
||||
@@ -958,7 +958,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
|
||||
assert_eq!(response_json["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
response_json["error"]["message"],
|
||||
"没有可用的提供商支持模型 gpt-5.4 的同步请求"
|
||||
"没有可用提供商支持模型 gpt-5.4 的同步请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
|
||||
@@ -187,10 +187,11 @@ async fn gateway_handles_admin_pool_scheduling_presets_locally_with_trusted_admi
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let items = payload.as_array().expect("payload should be an array");
|
||||
assert_eq!(items.len(), 13);
|
||||
assert_eq!(items.len(), 14);
|
||||
assert_eq!(items[0]["name"], "lru");
|
||||
assert_eq!(items[1]["name"], "cache_affinity");
|
||||
assert_eq!(items[12]["name"], "team_first");
|
||||
assert_eq!(items[8]["name"], "pro_first");
|
||||
assert_eq!(items[13]["name"], "team_first");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -227,7 +227,7 @@ async fn gateway_locally_denies_gemini_files_download_control_sync_even_with_opt
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"Gemini files execution runtime miss did not match a Rust execution path"
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -306,7 +306,7 @@ async fn gateway_locally_denies_gemini_files_download_control_sync_without_opt_i
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"Gemini files execution runtime miss did not match a Rust execution path"
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -378,7 +378,7 @@ async fn gateway_skips_gemini_files_download_control_sync_without_opt_in_header(
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"Gemini files execution runtime miss did not match a Rust execution path"
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -338,7 +338,7 @@ async fn gateway_locally_denies_gemini_files_upload_control_sync_with_opt_in_hea
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"Gemini files execution runtime miss did not match a Rust execution path"
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -403,7 +403,7 @@ async fn gateway_locally_denies_gemini_files_upload_control_sync_without_opt_in_
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"Gemini files execution runtime miss did not match a Rust execution path"
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -955,7 +955,7 @@ async fn gateway_records_failed_usage_for_claude_runtime_miss_without_execution_
|
||||
assert_eq!(body_json["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
body_json["error"]["message"],
|
||||
"没有可用的提供商支持模型 claude-sonnet-4-5 的同步请求"
|
||||
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
|
||||
let stored_usage = wait_for_usage_status(
|
||||
@@ -1576,7 +1576,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
|
||||
assert_eq!(body_json["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
body_json["error"]["message"],
|
||||
"没有可用的提供商支持模型 gpt-5.4 的同步请求"
|
||||
"没有可用提供商支持模型 gpt-5.4 的同步请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
|
||||
let stored_usage = wait_for_usage_status(
|
||||
@@ -1614,7 +1614,9 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
|
||||
);
|
||||
assert_eq!(
|
||||
stored_usage.error_message.as_deref(),
|
||||
Some("没有可用的提供商支持模型 gpt-5.4 的同步请求")
|
||||
Some(
|
||||
"没有可用提供商支持模型 gpt-5.4 的同步请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
stored_usage
|
||||
|
||||
@@ -71,7 +71,7 @@ async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path"
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -147,7 +147,7 @@ async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_ex
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path"
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -216,7 +216,7 @@ async fn gateway_skips_video_get_control_sync_without_opt_in_header() {
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path"
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
Reference in New Issue
Block a user