Compare commits

...
Author SHA1 Message Date
fawney 2cb4d554aa feat(routing): consolidate scheduling strategy configuration 2026-09-03 11:05:59 +08:00
fawney e8d9877b79 Merge remote-tracking branch 'origin/main' 2026-09-02 22:08:36 +08:00
fawney19 cae9aa4134 Merge pull request #784 from fawney19/worktree-linear-enchanting-bunny
feat(routing): move sticky-key retries into routing policy with lazy attempts
2026-09-02 20:56:03 +08:00
fawney19 e3644c6142 Merge pull request #779 from fawney19/worktree-linear-enchanting-bunny
feat(routing): make routing profiles the sole scheduler policy source
2026-09-02 17:16:57 +08:00
fawney 5059093d29 feat(vscodex): add remote Codex collaboration module 2026-09-01 20:08:36 +08:00
88 changed files with 1836 additions and 3635 deletions
+3 -3
View File
@@ -51,7 +51,7 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
ENCRYPTION_KEY=change-this-to-another-secure-random-string ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 启动自举管理员(仅在当前库里还没有活动管理员时生效) # 启动自举管理员(仅在当前库里还没有活动管理员时生效)
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。 # 首次启动前必须设置 ADMIN_PASSWORD;install.sh 首次生成配置时会提示输入。
ADMIN_EMAIL=[email protected] ADMIN_EMAIL=[email protected]
ADMIN_USERNAME=admin123456 ADMIN_USERNAME=admin123456
# ADMIN_PASSWORD= # ADMIN_PASSWORD=
@@ -69,8 +69,8 @@ ADMIN_USERNAME=admin123456
# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws # AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com # AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true) # 启动时的数据库准备策略:auto(默认)或 verify-only
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true # AETHER_GATEWAY_DATABASE_MODE=auto
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算) # PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12 # AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
+36 -12
View File
@@ -6,7 +6,7 @@ DEV_RUST_LOG := $(RUST_LOG)
endif endif
export DEV_RUST_LOG export DEV_RUST_LOG
.PHONY: dev dev-backend dev-frontend migration backfill .PHONY: dev dev-backend dev-frontend db-status db-prepare migration backfill
define DEV_BACKEND_SCRIPT define DEV_BACKEND_SCRIPT
set -euo pipefail set -euo pipefail
@@ -20,6 +20,13 @@ set -a
source .env source .env
set +a set +a
if [[ -n "$${ADMIN_EMAIL:-}" || -n "$${ADMIN_USERNAME:-}" || -n "$${ADMIN_PASSWORD:-}" ]]; then
if [[ -z "$${ADMIN_USERNAME:-}" || -z "$${ADMIN_PASSWORD:-}" ]]; then
echo "=> 管理员自举配置不完整,请在 .env 中设置 ADMIN_USERNAME 和 ADMIN_PASSWORD"
exit 1
fi
fi
dotenv_has_key() { dotenv_has_key() {
local key="$$1" local key="$$1"
grep -Eq "^[[:space:]]*$${key}=" .env grep -Eq "^[[:space:]]*$${key}=" .env
@@ -201,12 +208,17 @@ print_startup_failure_hint() {
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
if grep -Eq "database schema is behind" "$${log_file}"; then if grep -Eq "database schema is behind" "$${log_file}"; then
echo "=> 检测到数据库 schema 落后,请执行: make migration" echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
return return
fi fi
if grep -Eq "database backfills are behind" "$${log_file}"; then if grep -Eq "database backfills are behind" "$${log_file}"; then
echo "=> 检测到待执行 backfills,请执行: make backfill" echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
return
fi
if grep -Eq "bootstrap admin env is partially configured.*ADMIN_PASSWORD" "$${log_file}"; then
echo "=> 首次启动需要管理员密码,请在 .env 中设置 ADMIN_PASSWORD"
return return
fi fi
fi fi
@@ -352,8 +364,8 @@ create_gateway_log_file
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..." echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
echo "=> 日志过滤: $${RUST_LOG}" echo "=> 日志过滤: $${RUST_LOG}"
echo "=> 执行命令: cargo run -p aether-gateway -- --app-port $${APP_PORT}" echo "=> 执行命令: cargo run -p aether-gateway --bin aether-gateway -- --app-port $${APP_PORT}"
cargo run -p aether-gateway -- --app-port "$${APP_PORT}" > >( cargo run -p aether-gateway --bin aether-gateway -- --app-port "$${APP_PORT}" > >(
tee -a "$${GATEWAY_LOG_FILE}" tee -a "$${GATEWAY_LOG_FILE}"
) 2>&1 & ) 2>&1 &
GATEWAY_PID=$$! GATEWAY_PID=$$!
@@ -444,7 +456,7 @@ if [ -f .env ]; then
fi fi
export APP_PORT="$${APP_PORT:-8084}" export APP_PORT="$${APP_PORT:-8084}"
echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway -- --app-port $${APP_PORT:-8084}" echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway --bin aether-gateway -- --app-port $${APP_PORT:-8084}"
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" & /bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
backend_pid=$$! backend_pid=$$!
@@ -494,8 +506,14 @@ export DEV_SCRIPT
define DB_TASK_SCRIPT define DB_TASK_SCRIPT
set -euo pipefail set -euo pipefail
if [ -z "$${DB_TASK_FLAG:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then if [ -z "$${DB_TASK_COMMAND:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
echo "=> 内部错误: DB_TASK_FLAG / DB_TASK_LABEL 未设置" echo "=> 内部错误: DB_TASK_COMMAND / DB_TASK_LABEL 未设置"
exit 1
fi
read -r -a db_task_args <<< "$${DB_TASK_COMMAND}"
if [ "$${#db_task_args[@]}" -eq 0 ]; then
echo "=> 内部错误: DB_TASK_COMMAND 为空"
exit 1 exit 1
fi fi
@@ -546,8 +564,8 @@ if ! command -v cargo >/dev/null 2>&1; then
exit 1 exit 1
fi fi
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway -- $${DB_TASK_FLAG}" echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway --bin aether-gateway -- $${db_task_args[*]}"
exec cargo run -p aether-gateway -- "$${DB_TASK_FLAG}" exec cargo run -p aether-gateway --bin aether-gateway -- "$${db_task_args[@]}"
endef endef
export DB_TASK_SCRIPT export DB_TASK_SCRIPT
@@ -560,8 +578,14 @@ dev-backend:
dev-frontend: dev-frontend:
@cd frontend && npm run dev @cd frontend && npm run dev
db-status:
@DB_TASK_COMMAND="db status" DB_TASK_LABEL="数据库状态检查" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
db-prepare:
@DB_TASK_COMMAND="db prepare" DB_TASK_LABEL="数据库准备" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
migration: migration:
@DB_TASK_FLAG=--migrate DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT" @DB_TASK_COMMAND="--migrate" DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
backfill: backfill:
@DB_TASK_FLAG=--apply-backfills DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT" @DB_TASK_COMMAND="--apply-backfills" DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
+9 -1
View File
@@ -128,6 +128,7 @@ Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/f
## 本地开发 ## 本地开发
依赖 Docker、Rust toolchain、Node.js 和 make。 依赖 Docker、Rust toolchain、Node.js 和 make。
首次启动前需要在 `.env` 中设置 `ADMIN_PASSWORD`,用于创建本地管理员。
```bash ```bash
make dev make dev
@@ -135,6 +136,12 @@ make dev
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。 `make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。 Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
数据库 schema 和历史数据准备也会在启动时自动完成;通常不需要手动区分 migration 与 backfill。排查或部署前预执行时可使用:
```bash
make db-status
make db-prepare
```
## Codex 远程协同 ## Codex 远程协同
@@ -173,7 +180,8 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制 - `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置 - `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis - `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启 - `AETHER_GATEWAY_DATABASE_MODE=auto|verify-only`:数据库启动策略,默认 `auto`,自动完成挂起的 schema migration 和 backfill;`verify-only` 仅检查并在数据库落后时拒绝启动
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:旧版兼容开关;新配置请使用 `AETHER_GATEWAY_DATABASE_MODE`
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥 - `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk` - `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码 - `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
@@ -9,7 +9,7 @@ use aether_ai_serving::{
use aether_dispatch_core::{DispatchSequence, DispatchSequenceItem}; use aether_dispatch_core::{DispatchSequence, DispatchSequenceItem};
use aether_routing_core::{ use aether_routing_core::{
rank_vector_for_candidate, CandidateKind, ResolvedRoutingPolicy, RoutingCandidateFacts, rank_vector_for_candidate, CandidateKind, ResolvedRoutingPolicy, RoutingCandidateFacts,
RoutingCandidateTrace, RoutingDecisionTrace, RoutingCandidateTrace, RoutingDecisionTrace, RoutingExecutionPolicy,
}; };
use aether_scheduler_core::{ use aether_scheduler_core::{
ClientSessionAffinity, SchedulerMinimalCandidateSelectionCandidate, SchedulerRankingOutcome, ClientSessionAffinity, SchedulerMinimalCandidateSelectionCandidate, SchedulerRankingOutcome,
@@ -79,6 +79,13 @@ type DecorateSkippedCandidateFn<'a> = Arc<
pub(crate) trait LocalExecutionAttemptSource<T>: Send { pub(crate) trait LocalExecutionAttemptSource<T>: Send {
async fn next_execution_attempt(&mut self) -> Result<Option<T>, GatewayError>; async fn next_execution_attempt(&mut self) -> Result<Option<T>, GatewayError>;
/// Returns the request-scoped execution behaviour selected by routing.
/// Execution wrappers use this snapshot before consuming the first
/// attempt, avoiding a second lookup against mutable system settings.
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
None
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<T>, GatewayError>; async fn drain_execution_attempts(&mut self) -> Result<Vec<T>, GatewayError>;
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError>; async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError>;
@@ -1237,9 +1244,7 @@ async fn scheduler_cache_affinity_enabled(
state: PlannerAppState<'_>, state: PlannerAppState<'_>,
routing_policy: Option<&ResolvedRoutingPolicy>, routing_policy: Option<&ResolvedRoutingPolicy>,
) -> bool { ) -> bool {
scheduler_ordering_config_for_routing_policy(state, routing_policy) scheduler_ordering_config_for_routing_policy(routing_policy).scheduling_mode
.await
.scheduling_mode
== SchedulerSchedulingMode::CacheAffinity == SchedulerSchedulingMode::CacheAffinity
} }
@@ -6,14 +6,11 @@ use aether_ai_serving::{
use aether_routing_core::ResolvedRoutingPolicy; use aether_routing_core::ResolvedRoutingPolicy;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::warn;
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState}; use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_ms; use crate::clock::current_unix_ms;
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value; use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
use crate::scheduler::config::{ use crate::scheduler::config::{SchedulerOrderingConfig, SchedulerSchedulingMode};
read_scheduler_ordering_config, SchedulerOrderingConfig, SchedulerSchedulingMode,
};
use aether_scheduler_core::{ use aether_scheduler_core::{
matches_affinity_target, ClientSessionAffinity, SchedulerAffinityTarget, matches_affinity_target, ClientSessionAffinity, SchedulerAffinityTarget,
SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode, SchedulerRankableCandidate, SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode, SchedulerRankableCandidate,
@@ -133,7 +130,7 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
required_capabilities: Option<&serde_json::Value>, required_capabilities: Option<&serde_json::Value>,
routing_policy: Option<&ResolvedRoutingPolicy>, routing_policy: Option<&ResolvedRoutingPolicy>,
) -> Vec<EligibleLocalExecutionCandidate> { ) -> Vec<EligibleLocalExecutionCandidate> {
let ordering_config = scheduler_ordering_config_for_routing_policy(state, routing_policy).await; let ordering_config = scheduler_ordering_config_for_routing_policy(routing_policy);
let port = GatewayLocalCandidateRankingPort { let port = GatewayLocalCandidateRankingPort {
state, state,
requested_model, requested_model,
@@ -184,16 +181,24 @@ fn ai_ranking_scheduling_mode(mode: SchedulerSchedulingMode) -> AiRankingSchedul
} }
} }
/// Ordering config for a request. A resolved routing policy is authoritative /// Return the immutable scheduler snapshot carried by a resolved routing
/// and is never merged with legacy system-config values; without a policy the /// policy. A missing policy is a programming error in production request
/// effective default (system-default routing group, then legacy keys) applies. /// paths; unit tests may use the scheduler default for isolated ranking tests.
pub(crate) async fn scheduler_ordering_config_for_routing_policy( pub(crate) fn scheduler_ordering_config_for_routing_policy(
state: PlannerAppState<'_>,
routing_policy: Option<&ResolvedRoutingPolicy>, routing_policy: Option<&ResolvedRoutingPolicy>,
) -> SchedulerOrderingConfig { ) -> SchedulerOrderingConfig {
match routing_policy { match routing_policy {
Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy), Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy),
None => read_scheduler_ordering_config_or_default(state).await, None => {
#[cfg(test)]
{
SchedulerOrderingConfig::default()
}
#[cfg(not(test))]
{
panic!("resolved routing policy is required before candidate scheduling")
}
}
} }
} }
@@ -238,23 +243,6 @@ fn routing_overlaid_candidate(
overlaid overlaid
} }
async fn read_scheduler_ordering_config_or_default(
state: PlannerAppState<'_>,
) -> SchedulerOrderingConfig {
match read_scheduler_ordering_config(state.app()).await {
Ok(config) => config,
Err(error) => {
warn!(
event_name = "planner_scheduler_ordering_config_load_failed",
log_type = "event",
error = ?error,
"failed to load scheduler ordering config while ranking local execution candidates"
);
SchedulerOrderingConfig::default()
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -263,10 +251,16 @@ mod tests {
use aether_ai_serving::{ use aether_ai_serving::{
ai_ranking_context, build_ai_rankable_candidate, AiRankableCandidateParts, ai_ranking_context, build_ai_rankable_candidate, AiRankableCandidateParts,
}; };
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::{
provider_catalog::InMemoryProviderCatalogReadRepository,
routing_profiles::InMemoryRoutingGroupRepository,
};
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupWriteRepository,
};
use aether_scheduler_core::{ use aether_scheduler_core::{
apply_scheduler_candidate_ranking, apply_scheduler_candidate_ranking,
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session, build_scheduler_affinity_cache_key_for_api_key_id_with_client_session,
@@ -296,7 +290,11 @@ mod tests {
required_capabilities: Option<&serde_json::Value>, required_capabilities: Option<&serde_json::Value>,
) -> Vec<SchedulerMinimalCandidateSelectionCandidate> { ) -> Vec<SchedulerMinimalCandidateSelectionCandidate> {
let normalized_client_api_format = client_api_format.trim().to_ascii_lowercase(); let normalized_client_api_format = client_api_format.trim().to_ascii_lowercase();
let ordering_config = super::read_scheduler_ordering_config_or_default(state).await; let ordering_config =
crate::scheduler::config::read_system_default_routing_ordering_config(state.app())
.await
.expect("routing strategy should load")
.unwrap_or_default();
let mut candidates = candidates; let mut candidates = candidates;
let mut rankables = Vec::with_capacity(candidates.len()); let mut rankables = Vec::with_capacity(candidates.len());
let mut ordering_cache = CandidateTransportRankingFactsCache::default(); let mut ordering_cache = CandidateTransportRankingFactsCache::default();
@@ -372,6 +370,7 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity, scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: aether_routing_core::RankingOverlay::default(), ranking_overlay: aether_routing_core::RankingOverlay::default(),
mutation_plan: Default::default(), mutation_plan: Default::default(),
pool_policy_overrides: BTreeMap::new(), pool_policy_overrides: BTreeMap::new(),
@@ -408,17 +407,14 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: Default::default(), ranking_overlay: Default::default(),
mutation_plan: Default::default(), mutation_plan: Default::default(),
pool_policy_overrides: Default::default(), pool_policy_overrides: Default::default(),
matched_rules: Vec::new(), matched_rules: Vec::new(),
}; };
let ordering = super::scheduler_ordering_config_for_routing_policy( let ordering = super::scheduler_ordering_config_for_routing_policy(Some(&policy));
PlannerAppState::new(&state),
Some(&policy),
)
.await;
assert_eq!( assert_eq!(
ordering.scheduling_mode, ordering.scheduling_mode,
@@ -446,6 +442,7 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity, scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: aether_routing_core::RankingOverlay { ranking_overlay: aether_routing_core::RankingOverlay {
pool_priority_overrides: BTreeMap::from([("provider-1".to_string(), 4)]), pool_priority_overrides: BTreeMap::from([("provider-1".to_string(), 4)]),
key_priority_overrides: BTreeMap::from([("representative-key".to_string(), 1)]), key_priority_overrides: BTreeMap::from([("representative-key".to_string(), 1)]),
@@ -917,7 +914,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn local_execution_ranking_keeps_cross_format_priority_when_global_override_is_enabled() { async fn local_execution_ranking_keeps_cross_format_priority_when_strategy_override_is_enabled()
{
let provider_catalog = InMemoryProviderCatalogReadRepository::seed( let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![ vec![
sample_provider_with_options("provider-same", false, 10), sample_provider_with_options("provider-same", false, 10),
@@ -932,14 +930,32 @@ mod tests {
sample_key_for_provider("provider-cross", "key-cross", ""), sample_key_for_provider("provider-cross", "key-cross", ""),
], ],
); );
let routing_repository = std::sync::Arc::new(InMemoryRoutingGroupRepository::default());
routing_repository
.create_routing_group(CreateRoutingGroupRecord {
id: "strategy-default".to_string(),
name: "strategy-default".to_string(),
description: None,
enabled: true,
is_system_default: true,
sort_order: 0,
config_json: json!({
"default_policy": {
"keep_priority_on_conversion": true
}
}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("routing strategy should be created");
let data_state = GatewayDataState::with_provider_transport_reader_for_tests( let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog), std::sync::Arc::new(provider_catalog),
"development-key", "development-key",
) )
.with_system_config_values_for_tests(vec![( .with_routing_group_repository_for_tests(routing_repository);
"keep_priority_on_conversion".to_string(),
json!(true),
)]);
let state = AppState::new() let state = AppState::new()
.expect("state should build") .expect("state should build")
.with_data_state_for_tests(data_state); .with_data_state_for_tests(data_state);
@@ -384,8 +384,7 @@ async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
None None
} else { } else {
Some( Some(
scheduler_ordering_config_for_routing_policy(state, routing_policy) scheduler_ordering_config_for_routing_policy(routing_policy)
.await
.sticky_key_attempts, .sticky_key_attempts,
) )
}; };
@@ -174,8 +174,9 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
self.ranking_seed, self.ranking_seed,
false, false,
self.request_operation, self.request_operation,
self.routing_policy super::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.map(SchedulerOrderingConfig::from_routing_policy), self.routing_policy,
),
) )
.await?; .await?;
@@ -427,11 +428,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
); );
let ordering_config = let ordering_config =
super::candidate_ranking::scheduler_ordering_config_for_routing_policy( super::candidate_ranking::scheduler_ordering_config_for_routing_policy(routing_policy);
state,
routing_policy,
)
.await;
Self { Self {
state, state,
@@ -1293,9 +1290,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.then_some(self.client_session_affinity.as_ref()) .then_some(self.client_session_affinity.as_ref())
.flatten(), .flatten(),
self.ranking_seed, self.ranking_seed,
self.routing_policy self.ordering_config,
.as_ref()
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await?; .await?;
let skipped_candidates = skipped_candidates let skipped_candidates = skipped_candidates
@@ -1890,6 +1885,7 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: Default::default(), ranking_overlay: Default::default(),
mutation_plan: Default::default(), mutation_plan: Default::default(),
pool_policy_overrides: Default::default(), pool_policy_overrides: Default::default(),
@@ -1954,6 +1950,7 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: Default::default(), ranking_overlay: Default::default(),
mutation_plan: Default::default(), mutation_plan: Default::default(),
pool_policy_overrides: Default::default(), pool_policy_overrides: Default::default(),
@@ -2692,6 +2689,7 @@ mod tests {
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
keep_priority_on_conversion: true, keep_priority_on_conversion: true,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: Default::default(), ranking_overlay: Default::default(),
mutation_plan: Default::default(), mutation_plan: Default::default(),
pool_policy_overrides: Default::default(), pool_policy_overrides: Default::default(),
@@ -625,21 +625,17 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
GatewayRoutingSelectionError::NotFound(explicit_group.unwrap_or_default()), GatewayRoutingSelectionError::NotFound(explicit_group.unwrap_or_default()),
)); ));
} }
None return Err(routing_selection_error(
GatewayRoutingSelectionError::NoDefault,
));
} }
}; };
let Some((group_id, group_version, group_config_json, selection_source)) = selected_group let Some((group_id, group_version, group_config_json, selection_source)) = selected_group
else { else {
input.client_session_affinity = client_session_affinity_from_api_request( return Err(routing_selection_error(
client_api_format, GatewayRoutingSelectionError::NoDefault,
&parts.headers, ));
Some(body_json),
);
input.routing_policy = None;
input.routing_trace_seed = None;
input.routing_context = None;
return Ok(());
}; };
if try_attach_static_default_routing_policy_to_input( if try_attach_static_default_routing_policy_to_input(
@@ -863,6 +859,10 @@ fn routing_selection_error(error: GatewayRoutingSelectionError) -> GatewayError
GatewayRoutingSelectionError::Repository(message) => { GatewayRoutingSelectionError::Repository(message) => {
GatewayError::Internal(format!("routing group repository lookup failed: {message}")) GatewayError::Internal(format!("routing group repository lookup failed: {message}"))
} }
GatewayRoutingSelectionError::NoDefault => GatewayError::Client {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "no enabled routing strategy is configured for this request".to_string(),
},
error => GatewayError::Client { error => GatewayError::Client {
status: StatusCode::FORBIDDEN, status: StatusCode::FORBIDDEN,
message: error.to_string(), message: error.to_string(),
@@ -1207,6 +1207,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: false, is_system_default: false,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -26,7 +26,6 @@ use crate::ai_serving::{
}; };
use crate::client_session_affinity::client_session_affinity_from_api_request; use crate::client_session_affinity::client_session_affinity_from_api_request;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
use super::{ use super::{
@@ -141,10 +140,9 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
current_unix_secs(), current_unix_secs(),
false, false,
spec.operation.map(|operation| operation.as_str()), spec.operation.map(|operation| operation.as_str()),
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await?; .await?;
let outcome = materialize_local_execution_candidates_with_serving( let outcome = materialize_local_execution_candidates_with_serving(
@@ -251,10 +249,9 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
current_unix_secs(), current_unix_secs(),
false, false,
spec.operation.map(|operation| operation.as_str()), spec.operation.map(|operation| operation.as_str()),
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await?; .await?;
@@ -26,6 +26,7 @@ use super::{
LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput, LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderSpec, LocalSameFormatProviderSpec,
}; };
use aether_routing_core::RoutingExecutionPolicy;
pub(crate) struct LocalSameFormatProviderSyncAttemptSource<'a> { pub(crate) struct LocalSameFormatProviderSyncAttemptSource<'a> {
state: &'a AppState, state: &'a AppState,
@@ -189,6 +190,13 @@ pub(crate) async fn build_local_stream_attempt_source<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -234,6 +242,13 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncA
impl LocalExecutionAttemptSource<AiStreamAttempt> impl LocalExecutionAttemptSource<AiStreamAttempt>
for LocalSameFormatProviderStreamAttemptSource<'_> for LocalSameFormatProviderStreamAttemptSource<'_>
{ {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_stream_attempt(attempt).await? { match self.build_stream_attempt(attempt).await? {
@@ -21,7 +21,8 @@ use crate::client_session_affinity::{
}; };
use crate::orchestration::{ use crate::orchestration::{
insert_pool_key_lease_report_context_fields, ExecutionAttemptIdentity, insert_pool_key_lease_report_context_fields, ExecutionAttemptIdentity,
ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD, SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD, ROUTING_EXECUTION_POLICY_REPORT_FIELD, ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD,
SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD,
}; };
use crate::scheduler::affinity::insert_scheduler_affinity_policy_report_context_field; use crate::scheduler::affinity::insert_scheduler_affinity_policy_report_context_field;
@@ -112,6 +113,11 @@ pub(crate) fn build_local_execution_report_context(
} }
insert_pool_key_lease_report_context_fields(&mut extra_fields, parts.pool_key_lease); insert_pool_key_lease_report_context_fields(&mut extra_fields, parts.pool_key_lease);
insert_scheduler_affinity_policy_report_context_field(&mut extra_fields, parts.routing_policy); insert_scheduler_affinity_policy_report_context_field(&mut extra_fields, parts.routing_policy);
if let Some(policy) = parts.routing_policy {
if let Ok(value) = serde_json::to_value(policy.execution_policy) {
extra_fields.insert(ROUTING_EXECUTION_POLICY_REPORT_FIELD.to_string(), value);
}
}
if let Some(override_policy) = parts if let Some(override_policy) = parts
.routing_policy .routing_policy
.and_then(|policy| policy.pool_policy_overrides.get(parts.provider_id)) .and_then(|policy| policy.pool_policy_overrides.get(parts.provider_id))
@@ -17,6 +17,7 @@ use crate::ai_serving::{
resolve_gemini_files_sync_spec as resolve_sync_spec, LocalGeminiFilesSpec, resolve_gemini_files_sync_spec as resolve_sync_spec, LocalGeminiFilesSpec,
}; };
use crate::{AiExecutionDecision, AppState, GatewayError}; use crate::{AiExecutionDecision, AppState, GatewayError};
use aether_routing_core::RoutingExecutionPolicy;
use self::decision::maybe_build_local_gemini_files_decision_payload_for_candidate; use self::decision::maybe_build_local_gemini_files_decision_payload_for_candidate;
use self::support::{ use self::support::{
@@ -174,6 +175,13 @@ pub(crate) async fn build_local_gemini_files_stream_attempt_source_for_kind<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -212,6 +220,13 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptS
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAttemptSource<'_> { impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_stream_attempt(attempt).await? { match self.build_stream_attempt(attempt).await? {
@@ -26,7 +26,6 @@ use crate::ai_serving::{
}; };
use crate::client_session_affinity::client_session_affinity_from_parts; use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt; pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt;
@@ -109,10 +108,9 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await?; .await?;
let outcome = materialize_local_execution_candidates_with_serving( let outcome = materialize_local_execution_candidates_with_serving(
@@ -186,10 +184,9 @@ pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await?; .await?;
Ok(build_local_execution_candidate_attempt_source_with_serving( Ok(build_local_execution_candidate_attempt_source_with_serving(
@@ -19,6 +19,7 @@ use crate::ai_serving::{
resolve_local_image_sync_spec as resolve_sync_spec, resolve_local_image_sync_spec as resolve_sync_spec,
}; };
use crate::{AiExecutionDecision, AppState, GatewayError}; use crate::{AiExecutionDecision, AppState, GatewayError};
use aether_routing_core::RoutingExecutionPolicy;
use self::decision::maybe_build_local_openai_image_decision_payload_for_candidate; use self::decision::maybe_build_local_openai_image_decision_payload_for_candidate;
use self::support::{ use self::support::{
@@ -252,6 +253,13 @@ pub(crate) async fn build_local_image_stream_attempt_source_for_kind<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -290,6 +298,13 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptS
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAttemptSource<'_> { impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_stream_attempt(attempt).await? { match self.build_stream_attempt(attempt).await? {
@@ -27,7 +27,6 @@ use crate::ai_serving::{
}; };
use crate::client_session_affinity::client_session_affinity_from_parts; use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
@@ -128,10 +127,9 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
false, false,
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await .await
{ {
@@ -206,10 +204,9 @@ pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
false, false,
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await .await
{ {
@@ -16,6 +16,7 @@ use crate::ai_serving::{
LocalVideoCreateSpec, LocalVideoCreateSpec,
}; };
use crate::{AiExecutionDecision, AppState, GatewayError}; use crate::{AiExecutionDecision, AppState, GatewayError};
use aether_routing_core::RoutingExecutionPolicy;
use self::decision::maybe_build_local_video_create_decision_payload_for_candidate; use self::decision::maybe_build_local_video_create_decision_payload_for_candidate;
use self::support::{ use self::support::{
@@ -104,6 +105,13 @@ pub(crate) async fn build_local_video_sync_attempt_source_for_kind<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -29,7 +29,6 @@ use crate::ai_serving::{
}; };
use crate::client_session_affinity::client_session_affinity_from_parts; use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalVideoCreateCandidateAttempt; pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalVideoCreateCandidateAttempt;
@@ -134,10 +133,9 @@ pub(super) async fn list_local_video_create_candidate_attempts(
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
false, false,
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await .await
{ {
@@ -195,10 +193,9 @@ pub(super) async fn build_local_video_create_candidate_attempt_source<'a>(
input.client_session_affinity.as_ref(), input.client_session_affinity.as_ref(),
current_unix_secs(), current_unix_secs(),
false, false,
input crate::ai_serving::planner::candidate_ranking::scheduler_ordering_config_for_routing_policy(
.routing_policy input.routing_policy.as_ref(),
.as_ref() ),
.map(SchedulerOrderingConfig::from_routing_policy),
) )
.await .await
{ {
@@ -18,6 +18,7 @@ use crate::ai_serving::planner::spec_metadata::{
}; };
use crate::ai_serving::GatewayControlDecision; use crate::ai_serving::GatewayControlDecision;
use crate::{AiExecutionDecision, AppState, GatewayError}; use crate::{AiExecutionDecision, AppState, GatewayError};
use aether_routing_core::RoutingExecutionPolicy;
use super::candidates::{ use super::candidates::{
build_local_standard_candidate_attempt_source, resolve_local_standard_decision_input, build_local_standard_candidate_attempt_source, resolve_local_standard_decision_input,
@@ -177,6 +178,13 @@ pub(crate) async fn build_local_stream_attempt_source<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -220,6 +228,13 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSour
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalStandardStreamAttemptSource<'_> { impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalStandardStreamAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_stream_attempt(attempt).await? { match self.build_stream_attempt(attempt).await? {
@@ -1,3 +1,4 @@
use aether_routing_core::RoutingExecutionPolicy;
use async_trait::async_trait; use async_trait::async_trait;
use std::collections::VecDeque; use std::collections::VecDeque;
use tracing::warn; use tracing::warn;
@@ -119,6 +120,13 @@ pub(crate) async fn build_local_openai_chat_stream_attempt_source<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiChatStreamAttemptSource<'_> { impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiChatStreamAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
let select_started_at = std::time::Instant::now(); let select_started_at = std::time::Instant::now();
let selected = self.next_execution_attempt_with_target_select().await?; let selected = self.next_execution_attempt_with_target_select().await?;
@@ -1,3 +1,4 @@
use aether_routing_core::RoutingExecutionPolicy;
use async_trait::async_trait; use async_trait::async_trait;
use tracing::warn; use tracing::warn;
@@ -92,6 +93,13 @@ pub(crate) async fn build_local_openai_chat_sync_attempt_source<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiChatSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiChatSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -1,3 +1,4 @@
use aether_routing_core::RoutingExecutionPolicy;
use async_trait::async_trait; use async_trait::async_trait;
use tracing::warn; use tracing::warn;
@@ -161,6 +162,13 @@ pub(super) async fn build_local_stream_attempt_source<'a>(
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAttemptSource<'_> { impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_sync_attempt(attempt).await? { match self.build_sync_attempt(attempt).await? {
@@ -204,6 +212,13 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAtte
#[async_trait] #[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiResponsesStreamAttemptSource<'_> { impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiResponsesStreamAttemptSource<'_> {
fn routing_execution_policy(&self) -> Option<RoutingExecutionPolicy> {
self.input
.routing_policy
.as_ref()
.map(|policy| policy.execution_policy)
}
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> { async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await? { while let Some(attempt) = self.candidates.next_attempt().await? {
match self.build_stream_attempt(attempt).await? { match self.build_stream_attempt(attempt).await? {
@@ -12,9 +12,8 @@ use crate::scheduler::config::SchedulerOrderingConfig;
use crate::GatewayError; use crate::GatewayError;
impl<'a> PlannerAppState<'a> { impl<'a> PlannerAppState<'a> {
/// `ordering_config` is the request's routing-policy derived scheduler /// `ordering_config` is the immutable scheduler snapshot derived from the
/// config (see `SchedulerOrderingConfig::from_routing_policy`). `None` /// request's resolved routing policy.
/// falls back to the runtime default.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates( pub(crate) async fn list_selectable_candidates(
self, self,
@@ -26,7 +25,7 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
crate::scheduler::candidate::list_selectable_candidates( crate::scheduler::candidate::list_selectable_candidates(
self.app().data.as_ref(), self.app().data.as_ref(),
@@ -55,7 +54,7 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -90,7 +89,7 @@ impl<'a> PlannerAppState<'a> {
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
request_operation: Option<&str>, request_operation: Option<&str>,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -146,7 +145,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -177,7 +176,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS); let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1)); let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
@@ -5077,6 +5077,7 @@ mod tests {
scheduling_mode: RoutingSchedulingMode::CacheAffinity, scheduling_mode: RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: RankingOverlay { ranking_overlay: RankingOverlay {
allowed_keys: key_ids.into_iter().map(str::to_string).collect(), allowed_keys: key_ids.into_iter().map(str::to_string).collect(),
..RankingOverlay::default() ..RankingOverlay::default()
@@ -118,8 +118,7 @@ use crate::execution_runtime::{
use crate::log_ids::short_request_id; use crate::log_ids::short_request_id;
use crate::orchestration::{ use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata, classify_failure_disposition, apply_local_execution_effect, build_local_error_flow_metadata, classify_failure_disposition,
cyber_continue_failover_enabled, spawn_local_oauth_success_effect, spawn_local_oauth_success_effect, trace_upstream_response_body, with_error_flow_report_context,
trace_upstream_response_body, with_error_flow_report_context,
with_upstream_response_report_context, FailureDisposition, FailureTokenAction, with_upstream_response_report_context, FailureDisposition, FailureTokenAction,
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect, LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
LocalExecutionEffect, LocalExecutionEffectContext, LocalFailoverAnalysis, LocalExecutionEffect, LocalExecutionEffectContext, LocalFailoverAnalysis,
@@ -6173,7 +6172,10 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
} }
let prefetch_for_cyber_failover = let prefetch_for_cyber_failover =
is_openai_responses_family_format(plan.provider_api_format.as_str()) is_openai_responses_family_format(plan.provider_api_format.as_str())
&& cyber_continue_failover_enabled(state).await; && crate::orchestration::routing_execution_policy_from_report_context(
report_context.as_ref(),
)
.is_some_and(|policy| policy.cyber_continue_failover);
let stream_commit_policy = StreamCommitPolicy::for_response( let stream_commit_policy = StreamCommitPolicy::for_response(
direct_stream_finalize_kind.is_some(), direct_stream_finalize_kind.is_some(),
upstream_content_type, upstream_content_type,
@@ -8501,14 +8503,6 @@ mod tests {
Arc::new(provider_catalog), Arc::new(provider_catalog),
"development-key", "development-key",
); );
let data_state = if continue_failover {
data_state.with_system_config_values_for_tests([(
crate::orchestration::CYBER_CONTINUE_FAILOVER_CONFIG_KEY.to_string(),
json!(true),
)])
} else {
data_state
};
let state = AppState::new() let state = AppState::new()
.expect("app state should build") .expect("app state should build")
.with_data_state_for_tests(data_state); .with_data_state_for_tests(data_state);
@@ -8557,7 +8551,10 @@ mod tests {
"candidate_index": 0, "candidate_index": 0,
"retry_index": 0, "retry_index": 0,
"provider_api_format": "openai:responses", "provider_api_format": "openai:responses",
"client_api_format": "openai:responses" "client_api_format": "openai:responses",
"routing_execution_policy": {
"cyber_continue_failover": continue_failover
}
})), })),
crate::clock::current_unix_ms(), crate::clock::current_unix_ms(),
Instant::now(), Instant::now(),
@@ -10515,7 +10512,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn prefetched_codex_cyber_policy_violation_retries_when_system_setting_is_enabled() { async fn prefetched_codex_cyber_policy_violation_retries_when_routing_strategy_is_enabled() {
assert!( assert!(
execute_prefetched_codex_cyber_policy_failure(true) execute_prefetched_codex_cyber_policy_failure(true)
.await .await
@@ -54,13 +54,10 @@ use crate::executor::{
record_failed_usage_for_exhausted_request, LocalExecutionExhaustion, record_failed_usage_for_exhausted_request, LocalExecutionExhaustion,
LocalExecutionRequestOutcome, LocalExecutionRequestOutcome,
}; };
use crate::handlers::shared::system_config_bool;
use crate::request_diagnostics::{current_request_diagnostics, scope_request_diagnostics_with}; use crate::request_diagnostics::{current_request_diagnostics, scope_request_diagnostics_with};
use crate::stage_metrics::observe_gateway_stage_ms; use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AiExecutionDecision, AppState, GatewayError}; use crate::{AiExecutionDecision, AppState, GatewayError};
const ENABLE_OPENAI_IMAGE_SYNC_HEARTBEAT_CONFIG_KEY: &str = "enable_openai_image_sync_heartbeat";
const ENABLE_STANDARD_TEXT_SYNC_HEARTBEAT_CONFIG_KEY: &str = "enable_standard_text_sync_heartbeat";
const OPENAI_IMAGE_SYNC_HEARTBEAT_INTERNAL_ERROR_STATUS: u16 = 502; const OPENAI_IMAGE_SYNC_HEARTBEAT_INTERNAL_ERROR_STATUS: u16 = 502;
const OPENAI_IMAGE_SYNC_HEARTBEAT_EXHAUSTED_STATUS: u16 = 503; const OPENAI_IMAGE_SYNC_HEARTBEAT_EXHAUSTED_STATUS: u16 = 503;
const OPENAI_IMAGE_SYNC_HEARTBEAT_ERROR_MESSAGE_LIMIT: usize = 4096; const OPENAI_IMAGE_SYNC_HEARTBEAT_ERROR_MESSAGE_LIMIT: usize = 4096;
@@ -107,7 +104,10 @@ pub(crate) async fn maybe_execute_sync_via_local_decision(
return Ok(LocalExecutionRequestOutcome::NoPath); return Ok(LocalExecutionRequestOutcome::NoPath);
}; };
if standard_text_sync_heartbeat_should_wrap(state, plan_kind).await { if standard_text_sync_heartbeat_should_wrap(
plan_kind,
attempt_source.routing_execution_policy(),
) {
let parts_for_task = parts.clone(); let parts_for_task = parts.clone();
let body_json_for_task = body_json.clone(); let body_json_for_task = body_json.clone();
let transfer_tracker_for_task = transfer_tracker.clone(); let transfer_tracker_for_task = transfer_tracker.clone();
@@ -264,7 +264,10 @@ pub(crate) async fn maybe_execute_sync_via_local_openai_responses_decision(
return Ok(LocalExecutionRequestOutcome::NoPath); return Ok(LocalExecutionRequestOutcome::NoPath);
}; };
if standard_text_sync_heartbeat_should_wrap(state, plan_kind).await { if standard_text_sync_heartbeat_should_wrap(
plan_kind,
attempt_source.routing_execution_policy(),
) {
let parts_for_task = parts.clone(); let parts_for_task = parts.clone();
let body_json_for_task = body_json.clone(); let body_json_for_task = body_json.clone();
let transfer_tracker_for_task = transfer_tracker.clone(); let transfer_tracker_for_task = transfer_tracker.clone();
@@ -381,7 +384,10 @@ pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
return Ok(LocalExecutionRequestOutcome::NoPath); return Ok(LocalExecutionRequestOutcome::NoPath);
}; };
if standard_text_sync_heartbeat_should_wrap(state, plan_kind).await { if standard_text_sync_heartbeat_should_wrap(
plan_kind,
attempt_source.routing_execution_policy(),
) {
let parts_for_task = parts.clone(); let parts_for_task = parts.clone();
let body_json_for_task = body_json.clone(); let body_json_for_task = body_json.clone();
let transfer_tracker_for_task = transfer_tracker.clone(); let transfer_tracker_for_task = transfer_tracker.clone();
@@ -609,7 +615,10 @@ pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
return Ok(LocalExecutionRequestOutcome::NoPath); return Ok(LocalExecutionRequestOutcome::NoPath);
}; };
if standard_text_sync_heartbeat_should_wrap(state, plan_kind).await { if standard_text_sync_heartbeat_should_wrap(
plan_kind,
attempt_source.routing_execution_policy(),
) {
let parts_for_task = parts.clone(); let parts_for_task = parts.clone();
let body_json_for_task = body_json.clone(); let body_json_for_task = body_json.clone();
let transfer_tracker_for_task = transfer_tracker.clone(); let transfer_tracker_for_task = transfer_tracker.clone();
@@ -746,42 +755,6 @@ pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
.await .await
} }
async fn openai_image_sync_heartbeat_enabled(state: &AppState) -> bool {
match state
.read_system_config_json_value(ENABLE_OPENAI_IMAGE_SYNC_HEARTBEAT_CONFIG_KEY)
.await
{
Ok(value) => system_config_bool(value.as_ref(), false),
Err(err) => {
tracing::warn!(
event_name = "openai_image_sync_heartbeat_config_read_failed",
log_type = "ops",
error = ?err,
"gateway failed to read sync image heartbeat config; defaulting disabled"
);
false
}
}
}
async fn standard_text_sync_heartbeat_enabled(state: &AppState) -> bool {
match state
.read_system_config_json_value(ENABLE_STANDARD_TEXT_SYNC_HEARTBEAT_CONFIG_KEY)
.await
{
Ok(value) => system_config_bool(value.as_ref(), false),
Err(err) => {
tracing::warn!(
event_name = "standard_text_sync_heartbeat_config_read_failed",
log_type = "ops",
error = ?err,
"gateway failed to read standard text sync heartbeat config; defaulting disabled"
);
false
}
}
}
fn standard_text_sync_heartbeat_applies_to_plan_kind(plan_kind: &str) -> bool { fn standard_text_sync_heartbeat_applies_to_plan_kind(plan_kind: &str) -> bool {
matches!( matches!(
plan_kind, plan_kind,
@@ -795,9 +768,12 @@ fn standard_text_sync_heartbeat_applies_to_plan_kind(plan_kind: &str) -> bool {
) )
} }
async fn standard_text_sync_heartbeat_should_wrap(state: &AppState, plan_kind: &str) -> bool { fn standard_text_sync_heartbeat_should_wrap(
plan_kind: &str,
execution_policy: Option<aether_routing_core::RoutingExecutionPolicy>,
) -> bool {
standard_text_sync_heartbeat_applies_to_plan_kind(plan_kind) standard_text_sync_heartbeat_applies_to_plan_kind(plan_kind)
&& standard_text_sync_heartbeat_enabled(state).await && execution_policy.is_some_and(|policy| policy.enable_cf_heartbeat)
} }
fn standard_text_sync_heartbeat_client_api_format_for_plan_kind(plan_kind: &str) -> &'static str { fn standard_text_sync_heartbeat_client_api_format_for_plan_kind(plan_kind: &str) -> &'static str {
@@ -1329,7 +1305,10 @@ pub(crate) async fn maybe_execute_sync_via_local_image_decision(
return Ok(LocalExecutionRequestOutcome::NoPath); return Ok(LocalExecutionRequestOutcome::NoPath);
}; };
if openai_image_sync_heartbeat_enabled(state).await { if attempt_source
.routing_execution_policy()
.is_some_and(|policy| policy.enable_cf_heartbeat)
{
let mut attempts = Vec::new(); let mut attempts = Vec::new();
while let Some(attempt) = attempt_source.next_execution_attempt().await? { while let Some(attempt) = attempt_source.next_execution_attempt().await? {
attempts.push(attempt); attempts.push(attempt);
@@ -1867,11 +1846,10 @@ mod tests {
assert_eq!(body, json!({"data": [{"b64_json": "x"}]})); assert_eq!(body, json!({"data": [{"b64_json": "x"}]}));
} }
#[tokio::test] #[test]
async fn openai_image_sync_heartbeat_missing_config_defaults_disabled() { fn openai_image_sync_heartbeat_missing_routing_policy_defaults_disabled() {
let state = AppState::new().expect("state should build"); assert!(!Option::<aether_routing_core::RoutingExecutionPolicy>::None
.is_some_and(|policy| policy.enable_cf_heartbeat));
assert!(!openai_image_sync_heartbeat_enabled(&state).await);
} }
#[tokio::test] #[tokio::test]
@@ -2149,23 +2127,9 @@ mod tests {
assert_eq!(body, json!({"data": [{"b64_json": "fallback-provider"}]})); assert_eq!(body, json!({"data": [{"b64_json": "fallback-provider"}]}));
} }
#[tokio::test]
async fn standard_text_sync_heartbeat_missing_config_defaults_disabled() {
let state = AppState::new().expect("state should build");
assert!(!standard_text_sync_heartbeat_enabled(&state).await);
}
#[tokio::test] #[tokio::test]
async fn standard_text_sync_heartbeat_no_local_candidates_preserves_no_path() { async fn standard_text_sync_heartbeat_no_local_candidates_preserves_no_path() {
let state = AppState::new() let state = AppState::new().expect("state should build");
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests([(
ENABLE_STANDARD_TEXT_SYNC_HEARTBEAT_CONFIG_KEY.to_string(),
json!(true),
)]),
);
let (parts, _) = http::Request::builder() let (parts, _) = http::Request::builder()
.method(http::Method::POST) .method(http::Method::POST)
.uri("/v1/responses") .uri("/v1/responses")
@@ -67,11 +67,14 @@ pub(crate) async fn build_admin_global_model_routing_payload(
.push(key); .push(key);
} }
// Effective default scheduling: system-default routing group first, then // The admin view reports the system-default routing strategy.
// legacy system-config keys. let ordering_config =
let ordering_config = crate::scheduler::config::read_scheduler_ordering_config(state.app()) match crate::scheduler::config::read_system_default_routing_ordering_config(state.app())
.await .await
.unwrap_or_default(); {
Ok(Some(config)) => config,
Ok(None) | Err(_) => crate::scheduler::config::SchedulerOrderingConfig::default(),
};
let scheduling_mode = ordering_config.scheduling_mode_str().to_string(); let scheduling_mode = ordering_config.scheduling_mode_str().to_string();
let priority_mode = ordering_config.priority_mode_str().to_string(); let priority_mode = ordering_config.priority_mode_str().to_string();
let keep_priority_on_conversion = ordering_config.keep_priority_on_conversion; let keep_priority_on_conversion = ordering_config.keep_priority_on_conversion;
@@ -265,7 +265,9 @@ pub(super) async fn build_admin_monitoring_cache_snapshot(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
) -> Result<AdminMonitoringCacheSnapshot, GatewayError> { ) -> Result<AdminMonitoringCacheSnapshot, GatewayError> {
let ordering_config = let ordering_config =
crate::scheduler::config::read_scheduler_ordering_config(state.app()).await?; crate::scheduler::config::read_system_default_routing_ordering_config(state.app())
.await?
.unwrap_or_default();
let scheduling_mode = ordering_config.scheduling_mode_str().to_string(); let scheduling_mode = ordering_config.scheduling_mode_str().to_string();
let provider_priority_mode = ordering_config.priority_mode_str().to_string(); let provider_priority_mode = ordering_config.priority_mode_str().to_string();
@@ -44,6 +44,8 @@ struct AdminRoutingGroupCreateRequest {
#[serde(default)] #[serde(default)]
is_system_default: bool, is_system_default: bool,
#[serde(default)] #[serde(default)]
sort_order: i64,
#[serde(default)]
config_json: Option<Value>, config_json: Option<Value>,
} }
@@ -137,6 +139,7 @@ async fn maybe_build_routing_groups_response(
description: payload.description, description: payload.description,
enabled: payload.enabled, enabled: payload.enabled,
is_system_default: payload.is_system_default, is_system_default: payload.is_system_default,
sort_order: payload.sort_order,
config_json, config_json,
version: 1, version: 1,
created_at: now, created_at: now,
@@ -464,6 +467,9 @@ fn build_routing_group_update_patch(
if let Some(value) = object.get("is_system_default") { if let Some(value) = object.get("is_system_default") {
patch.is_system_default = Some(required_bool(value, "is_system_default")?); patch.is_system_default = Some(required_bool(value, "is_system_default")?);
} }
if let Some(value) = object.get("sort_order") {
patch.sort_order = Some(required_i64(value, "sort_order")?.max(0));
}
if let Some(value) = object.get("config_json") { if let Some(value) = object.get("config_json") {
validate_config_json(value)?; validate_config_json(value)?;
patch.config_json = Some(value.clone()); patch.config_json = Some(value.clone());
@@ -604,6 +610,7 @@ fn routing_group_payload(group: &StoredRoutingGroup) -> Value {
"description": group.description, "description": group.description,
"enabled": group.enabled, "enabled": group.enabled,
"is_system_default": group.is_system_default, "is_system_default": group.is_system_default,
"sort_order": group.sort_order,
"config_json": group.config_json, "config_json": group.config_json,
"version": group.version, "version": group.version,
"created_at": group.created_at, "created_at": group.created_at,
+1 -22
View File
@@ -68,7 +68,6 @@ use crate::scheduler::candidate::{
is_auth_api_key_concurrency_limit_skip_reason, AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON, is_auth_api_key_concurrency_limit_skip_reason, AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON,
LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON, LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON,
}; };
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerSchedulingMode};
use crate::stage_metrics::observe_gateway_stage_ms; use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{ use crate::{
AppState, FrontdoorUserRpmOutcome, GatewayError, GatewayFallbackMetricKind, AppState, FrontdoorUserRpmOutcome, GatewayError, GatewayFallbackMetricKind,
@@ -411,27 +410,7 @@ async fn maybe_forward_public_request_to_tunnel_owner(
policy_context, policy_context,
) )
} else { } else {
let cache_affinity_enabled = match read_scheduler_ordering_config(state).await { return Ok(None);
Ok(config) => config.scheduling_mode == SchedulerSchedulingMode::CacheAffinity,
Err(err) => {
warn!(
trace_id = %request_context.trace_id,
error = ?err,
"gateway failed to load scheduler config while checking tunnel affinity forwarding mode"
);
SchedulerSchedulingMode::default() == SchedulerSchedulingMode::CacheAffinity
}
};
if !cache_affinity_enabled {
return Ok(None);
}
crate::scheduler::affinity::read_cached_scheduler_affinity_target(
state,
&auth_context.api_key_id,
affinity_context.client_session_affinity.as_ref(),
api_format,
&affinity_context.requested_model,
)
}; };
let Some(target) = target else { let Some(target) = target else {
return Ok(None); return Ok(None);
+362 -71
View File
@@ -76,6 +76,29 @@ enum DatabaseDriverArg {
Postgres, Postgres,
} }
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum DatabaseModeArg {
Auto,
VerifyOnly,
}
fn resolve_database_mode(
configured: Option<DatabaseModeArg>,
legacy_auto_prepare: Option<bool>,
) -> DatabaseModeArg {
if let Some(configured) = configured {
return configured;
}
if let Some(legacy_auto_prepare) = legacy_auto_prepare {
return if legacy_auto_prepare {
DatabaseModeArg::Auto
} else {
DatabaseModeArg::VerifyOnly
};
}
DatabaseModeArg::Auto
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum ExportDomainArg { enum ExportDomainArg {
Users, Users,
@@ -538,46 +561,71 @@ fn automatic_sql_pool_config_for_parallelism(
#[derive(ClapArgs, Debug, Clone)] #[derive(ClapArgs, Debug, Clone)]
struct GatewayDataArgs { struct GatewayDataArgs {
#[arg(long, env = "AETHER_DATABASE_DRIVER")] #[arg(long, env = "AETHER_DATABASE_DRIVER", global = true)]
database_driver: Option<DatabaseDriverArg>, database_driver: Option<DatabaseDriverArg>,
#[arg(long, env = "AETHER_DATABASE_URL")] #[arg(long, env = "AETHER_DATABASE_URL", global = true)]
database_url: Option<String>, database_url: Option<String>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_URL")] #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_URL", global = true)]
postgres_url: Option<String>, postgres_url: Option<String>,
#[arg(long, env = "AETHER_GATEWAY_DATA_ENCRYPTION_KEY")] #[arg(long, env = "AETHER_GATEWAY_DATA_ENCRYPTION_KEY", global = true)]
encryption_key: Option<String>, encryption_key: Option<String>,
#[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_URL")] #[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_URL", global = true)]
redis_url: Option<String>, redis_url: Option<String>,
#[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_KEY_PREFIX")] #[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_KEY_PREFIX", global = true)]
redis_key_prefix: Option<String>, redis_key_prefix: Option<String>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS",
global = true
)]
postgres_min_connections: Option<u32>, postgres_min_connections: Option<u32>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS",
global = true
)]
postgres_max_connections: Option<u32>, postgres_max_connections: Option<u32>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_ACQUIRE_TIMEOUT_MS")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_ACQUIRE_TIMEOUT_MS",
global = true
)]
postgres_acquire_timeout_ms: Option<u64>, postgres_acquire_timeout_ms: Option<u64>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_IDLE_TIMEOUT_MS")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_IDLE_TIMEOUT_MS",
global = true
)]
postgres_idle_timeout_ms: Option<u64>, postgres_idle_timeout_ms: Option<u64>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_LIFETIME_MS")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_LIFETIME_MS",
global = true
)]
postgres_max_lifetime_ms: Option<u64>, postgres_max_lifetime_ms: Option<u64>,
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_CACHE_CAPACITY")] #[arg(
long,
env = "AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_CACHE_CAPACITY",
global = true
)]
postgres_statement_cache_capacity: Option<usize>, postgres_statement_cache_capacity: Option<usize>,
#[arg( #[arg(
long, long,
env = "AETHER_GATEWAY_DATA_POSTGRES_REQUIRE_SSL", env = "AETHER_GATEWAY_DATA_POSTGRES_REQUIRE_SSL",
default_value_t = false default_value_t = false,
global = true
)] )]
postgres_require_ssl: bool, postgres_require_ssl: bool,
} }
@@ -1144,13 +1192,26 @@ enum DataCommand {
Import(DataImportArgs), Import(DataImportArgs),
/// Copy persistent SQL data directly between two databases without a JSONL file. /// Copy persistent SQL data directly between two databases without a JSONL file.
Copy(DataCopyArgs), Copy(DataCopyArgs),
/// Inspect or prepare the configured database.
Db(DatabaseCommandArgs),
}
#[derive(ClapArgs, Debug, Clone)]
struct DatabaseCommandArgs {
#[command(subcommand)]
command: DatabaseCommand,
}
#[derive(Subcommand, Debug, Clone)]
enum DatabaseCommand {
/// Show whether schema migrations and data backfills are current.
Status,
/// Apply pending schema migrations and data backfills.
Prepare,
} }
#[derive(ClapArgs, Debug, Clone)] #[derive(ClapArgs, Debug, Clone)]
struct DataExportArgs { struct DataExportArgs {
#[command(flatten)]
data: GatewayDataArgs,
#[arg(long)] #[arg(long)]
output: PathBuf, output: PathBuf,
@@ -1160,9 +1221,6 @@ struct DataExportArgs {
#[derive(ClapArgs, Debug, Clone)] #[derive(ClapArgs, Debug, Clone)]
struct DataImportArgs { struct DataImportArgs {
#[command(flatten)]
data: GatewayDataArgs,
#[arg(long)] #[arg(long)]
input: PathBuf, input: PathBuf,
} }
@@ -1284,18 +1342,25 @@ struct Args {
)] )]
node_role: NodeRoleArg, node_role: NodeRoleArg,
#[arg(long, default_value_t = false)] #[arg(long, hide = true, default_value_t = false)]
migrate: bool, migrate: bool,
#[arg(long, default_value_t = false)] #[arg(long, hide = true, default_value_t = false)]
apply_backfills: bool, apply_backfills: bool,
/// Database startup policy. Defaults to auto when neither this nor the legacy setting is set.
#[arg(long, env = "AETHER_GATEWAY_DATABASE_MODE", value_enum)]
database_mode: Option<DatabaseModeArg>,
/// Legacy compatibility switch. Prefer --database-mode.
#[arg( #[arg(
long, long,
env = "AETHER_GATEWAY_AUTO_PREPARE_DATABASE", env = "AETHER_GATEWAY_AUTO_PREPARE_DATABASE",
default_value_t = false hide = true,
num_args = 0..=1,
default_missing_value = "true"
)] )]
auto_prepare_database: bool, auto_prepare_database: Option<bool>,
/// Path to frontend static files directory (SPA). When set, the gateway /// Path to frontend static files directory (SPA). When set, the gateway
/// serves the frontend directly without nginx. /// serves the frontend directly without nginx.
@@ -1405,6 +1470,10 @@ struct Args {
} }
impl Args { impl Args {
fn effective_database_mode(&self) -> DatabaseModeArg {
resolve_database_mode(self.database_mode, self.auto_prepare_database)
}
fn effective_runtime_backend( fn effective_runtime_backend(
&self, &self,
database: Option<&SqlDatabaseConfig>, database: Option<&SqlDatabaseConfig>,
@@ -1475,15 +1544,7 @@ impl Args {
} }
fn runtime_config(&self) -> Result<ServiceRuntimeConfig, std::io::Error> { fn runtime_config(&self) -> Result<ServiceRuntimeConfig, std::io::Error> {
let default_log_filter = if self.command.is_some() let default_log_filter = "aether_gateway=info,aether_data=info";
|| self.migrate
|| self.apply_backfills
|| self.auto_prepare_database
{
"aether_gateway=info,aether_data=info"
} else {
"aether_gateway=info"
};
let config = self let config = self
.logging .logging
.apply_to_runtime_config(ServiceRuntimeConfig::new( .apply_to_runtime_config(ServiceRuntimeConfig::new(
@@ -1786,7 +1847,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); let args = Args::parse();
if let Some(command) = args.command.as_ref() { if let Some(command) = args.command.as_ref() {
init_service_runtime(args.runtime_config()?)?; init_service_runtime(args.runtime_config()?)?;
return run_data_command(command).await; return run_data_command(command, &args.data).await;
} }
if args.migrate { if args.migrate {
init_service_runtime(args.runtime_config()?)?; init_service_runtime(args.runtime_config()?)?;
@@ -2086,7 +2147,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
execution_runtime_configured = state.execution_runtime_configured(), execution_runtime_configured = state.execution_runtime_configured(),
"aether-gateway data layer configured" "aether-gateway data layer configured"
); );
prepare_database_startup_requirements(&state, args.auto_prepare_database).await?; prepare_database_startup_requirements(&state, args.effective_database_mode()).await?;
state.warm_database_pools().await?; state.warm_database_pools().await?;
let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?; let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?;
if reset_stale_proxy_nodes > 0 { if reset_stale_proxy_nodes > 0 {
@@ -2101,16 +2162,11 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
info!( info!(
group_id = %group.id, group_id = %group.id,
group_name = %group.name, group_name = %group.name,
"created system default routing group from legacy scheduler config" "created system default routing group from routing strategy defaults"
); );
} }
Ok(None) => {} Ok(None) => {}
Err(err) => { Err(err) => return Err(err.into()),
warn!(
error = %err,
"failed to bootstrap system default routing group; scheduler falls back to legacy system config"
);
}
} }
match state.prewarm_chat_pii_redaction_runtime_config().await { match state.prewarm_chat_pii_redaction_runtime_config().await {
Ok(enabled) => { Ok(enabled) => {
@@ -2208,14 +2264,77 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
async fn run_data_command(command: &DataCommand) -> Result<(), Box<dyn std::error::Error>> { async fn run_data_command(
command: &DataCommand,
data: &GatewayDataArgs,
) -> Result<(), Box<dyn std::error::Error>> {
match command { match command {
DataCommand::Export(args) => run_data_export(args).await, DataCommand::Export(args) => run_data_export(args, data).await,
DataCommand::Import(args) => run_data_import(args).await, DataCommand::Import(args) => run_data_import(args, data).await,
DataCommand::Copy(args) => run_data_copy(args).await, DataCommand::Copy(args) => run_data_copy(args).await,
DataCommand::Db(args) => run_database_command(args, data).await,
} }
} }
async fn run_database_command(
args: &DatabaseCommandArgs,
data: &GatewayDataArgs,
) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
DatabaseCommand::Status => run_database_status(data).await,
DatabaseCommand::Prepare => run_database_prepare(data).await,
}
}
fn database_maintenance_state(
data: &GatewayDataArgs,
) -> Result<(DatabaseDriver, AppState), Box<dyn std::error::Error>> {
let database = required_sql_database_config(data)?;
let driver = database.driver;
let state = AppState::new()?.with_data_config(data.to_config())?;
Ok((driver, state))
}
async fn run_database_status(data: &GatewayDataArgs) -> Result<(), Box<dyn std::error::Error>> {
let (driver, state) = database_maintenance_state(data)?;
let pending_migrations = state
.pending_database_migrations()
.await?
.unwrap_or_default();
if let Some(next) = pending_migrations.first() {
println!("database {driver}: preparation required");
println!("pending migrations: {}", pending_migrations.len());
println!("next migration: {} ({})", next.version, next.description);
println!("pending backfills: not checked until migrations are current");
println!("run `aether-gateway db prepare`");
return Ok(());
}
let pending_backfills = state
.pending_database_backfills()
.await?
.unwrap_or_default();
if let Some(next) = pending_backfills.first() {
println!("database {driver}: preparation required");
println!("pending migrations: 0");
println!("pending backfills: {}", pending_backfills.len());
println!("next backfill: {} ({})", next.version, next.description);
println!("run `aether-gateway db prepare`");
return Ok(());
}
println!("database {driver}: ready (schema and backfills are current)");
Ok(())
}
async fn run_database_prepare(data: &GatewayDataArgs) -> Result<(), Box<dyn std::error::Error>> {
let (driver, state) = database_maintenance_state(data)?;
prepare_database_startup_requirements(&state, DatabaseModeArg::Auto).await?;
println!("database {driver}: ready (schema and backfills are current)");
Ok(())
}
fn required_sql_database_config( fn required_sql_database_config(
data: &GatewayDataArgs, data: &GatewayDataArgs,
) -> Result<SqlDatabaseConfig, Box<dyn std::error::Error>> { ) -> Result<SqlDatabaseConfig, Box<dyn std::error::Error>> {
@@ -2242,8 +2361,11 @@ fn current_unix_secs() -> Result<u64, std::time::SystemTimeError> {
.as_secs()) .as_secs())
} }
async fn run_data_export(args: &DataExportArgs) -> Result<(), Box<dyn std::error::Error>> { async fn run_data_export(
let database = required_sql_database_config(&args.data)?; args: &DataExportArgs,
data: &GatewayDataArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let database = required_sql_database_config(data)?;
let driver = database.driver; let driver = database.driver;
let domains = requested_export_domains(args); let domains = requested_export_domains(args);
let created_at_unix_secs = current_unix_secs()?; let created_at_unix_secs = current_unix_secs()?;
@@ -2265,8 +2387,11 @@ async fn run_data_export(args: &DataExportArgs) -> Result<(), Box<dyn std::error
Ok(()) Ok(())
} }
async fn run_data_import(args: &DataImportArgs) -> Result<(), Box<dyn std::error::Error>> { async fn run_data_import(
let database = required_sql_database_config(&args.data)?; args: &DataImportArgs,
data: &GatewayDataArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let database = required_sql_database_config(data)?;
let driver = database.driver; let driver = database.driver;
let input = tokio::fs::read_to_string(&args.input).await?; let input = tokio::fs::read_to_string(&args.input).await?;
let imported = import_database_jsonl(database, &input).await?; let imported = import_database_jsonl(database, &input).await?;
@@ -2426,17 +2551,15 @@ async fn run_explicit_backfills(args: &Args) -> Result<(), Box<dyn std::error::E
async fn prepare_database_startup_requirements( async fn prepare_database_startup_requirements(
state: &AppState, state: &AppState,
auto_prepare_database: bool, database_mode: DatabaseModeArg,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if !auto_prepare_database { if matches!(database_mode, DatabaseModeArg::VerifyOnly) {
ensure_database_schema_is_current(state).await?; ensure_database_schema_is_current(state).await?;
ensure_database_backfills_are_current(state).await?; ensure_database_backfills_are_current(state).await?;
return Ok(()); return Ok(());
} }
info!( info!("database preparation enabled; applying pending migrations and backfills");
"auto database preparation enabled; applying pending migrations and backfills before serving traffic"
);
let Some(pending_migrations) = state.prepare_database_for_startup().await? else { let Some(pending_migrations) = state.prepare_database_for_startup().await? else {
return Ok(()); return Ok(());
@@ -2450,10 +2573,10 @@ async fn prepare_database_startup_requirements(
next_version = next.version, next_version = next.version,
next_description = %next.description, next_description = %next.description,
pending_versions = %format_pending_migrations(&pending_migrations), pending_versions = %format_pending_migrations(&pending_migrations),
"running database migrations during service startup..." "running database migrations during database preparation..."
); );
if state.run_database_migrations().await? { if state.run_database_migrations().await? {
info!("database migrations complete during service startup"); info!("database migrations complete");
} }
} }
@@ -2472,10 +2595,10 @@ async fn prepare_database_startup_requirements(
next_version = next.version, next_version = next.version,
next_description = %next.description, next_description = %next.description,
pending_versions = %format_pending_backfills(&pending_backfills), pending_versions = %format_pending_backfills(&pending_backfills),
"running database backfills during service startup..." "running database backfills during database preparation..."
); );
if state.run_database_backfills().await? { if state.run_database_backfills().await? {
info!("database backfills complete during service startup"); info!("database backfills complete");
} }
Ok(()) Ok(())
@@ -2520,7 +2643,7 @@ async fn ensure_database_backfills_are_current(
async fn ensure_database_schema_is_current( async fn ensure_database_schema_is_current(
state: &AppState, state: &AppState,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let Some(pending) = state.prepare_database_for_startup().await? else { let Some(pending) = state.pending_database_migrations().await? else {
return Ok(()); return Ok(());
}; };
if pending.is_empty() { if pending.is_empty() {
@@ -2539,7 +2662,7 @@ fn pending_schema_error(
next_description: &str, next_description: &str,
) -> std::io::Error { ) -> std::io::Error {
std::io::Error::other(format!( std::io::Error::other(format!(
"database schema is behind by {} migration(s); next pending migration is {} ({})\nrun `aether-gateway --migrate` before starting the service", "database schema is behind by {} migration(s); next pending migration is {} ({})\nrun `aether-gateway db prepare` before starting the service",
pending_count, next_version, next_description pending_count, next_version, next_description
)) ))
} }
@@ -2550,7 +2673,7 @@ fn pending_backfills_error(
next_description: &str, next_description: &str,
) -> std::io::Error { ) -> std::io::Error {
std::io::Error::other(format!( std::io::Error::other(format!(
"database backfills are behind by {} backfill(s); next pending backfill is {} ({})\nrun `aether-gateway --apply-backfills` before starting the service", "database backfills are behind by {} backfill(s); next pending backfill is {} ({})\nrun `aether-gateway db prepare` before starting the service",
pending_count, next_version, next_description pending_count, next_version, next_description
)) ))
} }
@@ -2562,8 +2685,9 @@ mod tests {
automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config, automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config,
automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism, automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism,
ensure_database_backfills_are_current, ensure_database_schema_is_current, ensure_database_backfills_are_current, ensure_database_schema_is_current,
pending_backfills_error, pending_schema_error, resolve_healthcheck_url, pending_backfills_error, pending_schema_error, resolve_database_mode,
usage_database_config_for_role, Args, DatabaseDriverArg, DeploymentTopologyArg, resolve_healthcheck_url, usage_database_config_for_role, Args, DataCommand,
DatabaseCommand, DatabaseDriverArg, DatabaseModeArg, DeploymentTopologyArg,
GatewayDataArgs, GatewayFrontdoorArgs, GatewayLogDestinationArg, GatewayLogFormatArg, GatewayDataArgs, GatewayFrontdoorArgs, GatewayLogDestinationArg, GatewayLogFormatArg,
GatewayLogRotationArg, GatewayLoggingArgs, GatewayRateLimitArgs, GatewayUsageArgs, GatewayLogRotationArg, GatewayLoggingArgs, GatewayRateLimitArgs, GatewayUsageArgs,
NodeRoleArg, RuntimeBackendArg, VideoTaskTruthSourceArg, NodeRoleArg, RuntimeBackendArg, VideoTaskTruthSourceArg,
@@ -2574,6 +2698,7 @@ mod tests {
}; };
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig}; use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
use aether_gateway::AppState; use aether_gateway::AppState;
use clap::Parser;
fn test_args() -> Args { fn test_args() -> Args {
Args { Args {
@@ -2588,7 +2713,8 @@ mod tests {
node_role: NodeRoleArg::All, node_role: NodeRoleArg::All,
migrate: false, migrate: false,
apply_backfills: false, apply_backfills: false,
auto_prepare_database: false, database_mode: None,
auto_prepare_database: None,
static_dir: None, static_dir: None,
video_task_truth_source_mode: VideoTaskTruthSourceArg::PythonSyncReport, video_task_truth_source_mode: VideoTaskTruthSourceArg::PythonSyncReport,
video_task_poller_interval_ms: 5_000, video_task_poller_interval_ms: 5_000,
@@ -2690,6 +2816,19 @@ mod tests {
.expect("test database config should build") .expect("test database config should build")
} }
fn temporary_sqlite_args(label: &str) -> (Args, std::path::PathBuf) {
let mut args = test_args();
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be available")
.as_nanos();
let database_path =
std::env::temp_dir().join(format!("aether-{label}-{}-{nonce}.db", std::process::id()));
args.data.database_driver = Some(DatabaseDriverArg::Sqlite);
args.data.database_url = Some(format!("sqlite://{}", database_path.display()));
(args, database_path)
}
#[test] #[test]
fn resolves_healthcheck_url_from_app_port() { fn resolves_healthcheck_url_from_app_port() {
assert_eq!( assert_eq!(
@@ -2801,11 +2940,14 @@ mod tests {
} }
#[test] #[test]
fn normal_runtime_config_keeps_gateway_only_logs() { fn normal_runtime_config_includes_database_lifecycle_logs() {
let config = test_args() let config = test_args()
.runtime_config() .runtime_config()
.expect("runtime config should build"); .expect("runtime config should build");
assert_eq!(config.default_log_filter, "aether_gateway=info"); assert_eq!(
config.default_log_filter,
"aether_gateway=info,aether_data=info"
);
} }
#[test] #[test]
@@ -2822,7 +2964,7 @@ mod tests {
#[test] #[test]
fn auto_prepare_database_runtime_config_enables_data_logs() { fn auto_prepare_database_runtime_config_enables_data_logs() {
let mut args = test_args(); let mut args = test_args();
args.auto_prepare_database = true; args.auto_prepare_database = Some(true);
let config = args.runtime_config().expect("runtime config should build"); let config = args.runtime_config().expect("runtime config should build");
assert_eq!( assert_eq!(
config.default_log_filter, config.default_log_filter,
@@ -2830,6 +2972,87 @@ mod tests {
); );
} }
#[test]
fn database_mode_defaults_to_auto_and_preserves_legacy_false() {
assert_eq!(resolve_database_mode(None, None), DatabaseModeArg::Auto);
assert_eq!(
resolve_database_mode(None, Some(false)),
DatabaseModeArg::VerifyOnly
);
assert_eq!(
resolve_database_mode(Some(DatabaseModeArg::Auto), Some(false)),
DatabaseModeArg::Auto
);
}
#[test]
fn parses_database_commands_and_verify_only_mode() {
let status = Args::try_parse_from(["aether-gateway", "db", "status"])
.expect("db status should parse");
assert!(matches!(
status.command,
Some(DataCommand::Db(args))
if matches!(args.command, DatabaseCommand::Status)
));
let verify_only =
Args::try_parse_from(["aether-gateway", "--database-mode", "verify-only"])
.expect("verify-only mode should parse");
assert_eq!(
verify_only.effective_database_mode(),
DatabaseModeArg::VerifyOnly
);
let legacy_false =
Args::try_parse_from(["aether-gateway", "--auto-prepare-database=false"])
.expect("legacy false setting should parse");
assert_eq!(
legacy_false.effective_database_mode(),
DatabaseModeArg::VerifyOnly
);
let prepare = Args::try_parse_from(["aether-gateway", "db", "prepare"])
.expect("db prepare should parse");
assert!(matches!(
prepare.command,
Some(DataCommand::Db(args))
if matches!(args.command, DatabaseCommand::Prepare)
));
}
#[test]
fn database_arguments_are_global_for_database_commands() {
let before = Args::try_parse_from([
"aether-gateway",
"--database-driver",
"sqlite",
"--database-url",
"sqlite:///tmp/before.db",
"db",
"status",
])
.expect("database arguments before db should parse");
assert_eq!(
before.data.database_url.as_deref(),
Some("sqlite:///tmp/before.db")
);
let after = Args::try_parse_from([
"aether-gateway",
"db",
"prepare",
"--database-driver",
"sqlite",
"--database-url",
"sqlite:///tmp/after.db",
])
.expect("database arguments after db prepare should parse");
assert_eq!(
after.data.database_url.as_deref(),
Some("sqlite:///tmp/after.db")
);
}
#[test] #[test]
fn gateway_data_pool_auto_sizes_sqlite_to_single_connection() { fn gateway_data_pool_auto_sizes_sqlite_to_single_connection() {
let mut args = test_args(); let mut args = test_args();
@@ -3500,17 +3723,17 @@ mod tests {
} }
#[test] #[test]
fn pending_schema_error_mentions_explicit_migrate_command() { fn pending_schema_error_mentions_database_prepare_command() {
let error = pending_schema_error(2, 20260413020000, "squash usage schema split"); let error = pending_schema_error(2, 20260413020000, "squash usage schema split");
let message = error.to_string(); let message = error.to_string();
assert!(message.contains("database schema is behind by 2 migration(s)")); assert!(message.contains("database schema is behind by 2 migration(s)"));
assert!(message.contains("20260413020000")); assert!(message.contains("20260413020000"));
assert!(message.contains("squash usage schema split")); assert!(message.contains("squash usage schema split"));
assert!(message.contains("aether-gateway --migrate")); assert!(message.contains("aether-gateway db prepare"));
} }
#[test] #[test]
fn pending_backfills_error_mentions_explicit_apply_backfills_command() { fn pending_backfills_error_mentions_database_prepare_command() {
let message = pending_backfills_error( let message = pending_backfills_error(
1, 1,
20260422110000, 20260422110000,
@@ -3520,7 +3743,7 @@ mod tests {
assert!(message.contains("database backfills are behind by 1 backfill(s)")); assert!(message.contains("database backfills are behind by 1 backfill(s)"));
assert!(message.contains("20260422110000")); assert!(message.contains("20260422110000"));
assert!(message.contains("backfill stats aggregate read path support")); assert!(message.contains("backfill stats aggregate read path support"));
assert!(message.contains("aether-gateway --apply-backfills")); assert!(message.contains("aether-gateway db prepare"));
assert!(message.contains("before starting the service")); assert!(message.contains("before starting the service"));
} }
@@ -3543,11 +3766,79 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn auto_prepare_database_is_noop_without_database_pool() { async fn auto_prepare_database_is_noop_without_database_pool() {
let state = AppState::new().expect("state should build"); let state = AppState::new().expect("state should build");
super::prepare_database_startup_requirements(&state, true) super::prepare_database_startup_requirements(&state, DatabaseModeArg::Auto)
.await .await
.expect("disabled data backend should not block startup"); .expect("disabled data backend should not block startup");
} }
#[tokio::test]
async fn verify_only_does_not_prepare_fresh_sqlite_database() {
let (args, database_path) = temporary_sqlite_args("verify-only");
let state = AppState::new()
.expect("state should build")
.with_data_config(args.data.to_config())
.expect("sqlite state should build");
let pending_before = state
.pending_database_migrations()
.await
.expect("pending migrations should load")
.expect("sqlite should expose migration state");
assert!(!pending_before.is_empty());
let error =
super::prepare_database_startup_requirements(&state, DatabaseModeArg::VerifyOnly)
.await
.expect_err("verify-only should reject a fresh database");
assert!(error.to_string().contains("aether-gateway db prepare"));
let pending_after = state
.pending_database_migrations()
.await
.expect("pending migrations should reload")
.expect("sqlite should expose migration state");
assert_eq!(pending_after, pending_before);
drop(state);
let _ = std::fs::remove_file(database_path);
}
#[tokio::test]
async fn auto_mode_prepares_fresh_sqlite_database() {
let (args, database_path) = temporary_sqlite_args("auto-prepare");
let state = AppState::new()
.expect("state should build")
.with_data_config(args.data.to_config())
.expect("sqlite state should build");
super::prepare_database_startup_requirements(&state, DatabaseModeArg::Auto)
.await
.expect("auto mode should prepare a fresh database");
assert!(state
.pending_database_migrations()
.await
.expect("pending migrations should load")
.expect("sqlite should expose migration state")
.is_empty());
assert!(state
.pending_database_backfills()
.await
.expect("pending backfills should load")
.expect("sqlite should expose backfill state")
.is_empty());
drop(state);
let _ = std::fs::remove_file(database_path);
}
#[tokio::test]
async fn database_prepare_requires_database_url() {
let data = test_args().data;
let error = super::run_database_prepare(&data)
.await
.expect_err("missing database URL should fail");
assert!(error
.to_string()
.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL"));
}
#[tokio::test] #[tokio::test]
async fn explicit_migrate_requires_database_url() { async fn explicit_migrate_requires_database_url() {
let args = test_args(); let args = test_args();
@@ -53,7 +53,6 @@ use crate::scheduler::affinity::{
scheduler_affinity_policy_context_from_report_context, SCHEDULER_AFFINITY_POLICY_REPORT_FIELD, scheduler_affinity_policy_context_from_report_context, SCHEDULER_AFFINITY_POLICY_REPORT_FIELD,
SCHEDULER_AFFINITY_TTL, SCHEDULER_AFFINITY_TTL,
}; };
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerSchedulingMode};
use crate::AppState; use crate::AppState;
const POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES: usize = 50_000; const POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES: usize = 50_000;
@@ -763,36 +762,19 @@ async fn local_scheduler_affinity_matches_failed_target(
local_execution_plan_uses_pool(state, plan).await local_execution_plan_uses_pool(state, plan).await
} }
async fn scheduler_cache_affinity_enabled( fn scheduler_cache_affinity_enabled(report_context: Option<&Value>) -> bool {
state: &AppState, report_context
report_context: Option<&Value>,
) -> bool {
if report_context
.and_then(|context| context.get(SCHEDULER_AFFINITY_POLICY_REPORT_FIELD)) .and_then(|context| context.get(SCHEDULER_AFFINITY_POLICY_REPORT_FIELD))
.is_some() .is_some()
{ && scheduler_affinity_policy_context_from_report_context(report_context)
return scheduler_affinity_policy_context_from_report_context(report_context) .is_some_and(|context| context.cache_affinity_enabled())
.is_some_and(|context| context.cache_affinity_enabled());
}
match read_scheduler_ordering_config(state).await {
Ok(config) => config.scheduling_mode == SchedulerSchedulingMode::CacheAffinity,
Err(error) => {
warn!(
event_name = "orchestration_scheduler_affinity_config_load_failed",
log_type = "event",
error = ?error,
"failed to load scheduler config while checking cache affinity mode"
);
SchedulerSchedulingMode::default() == SchedulerSchedulingMode::CacheAffinity
}
}
} }
async fn remember_successful_local_scheduler_affinity( async fn remember_successful_local_scheduler_affinity(
state: &AppState, state: &AppState,
context: LocalExecutionEffectContext<'_>, context: LocalExecutionEffectContext<'_>,
) { ) {
if !scheduler_cache_affinity_enabled(state, context.report_context).await { if !scheduler_cache_affinity_enabled(context.report_context) {
return; return;
} }
let Some(cache_key) = local_scheduler_affinity_cache_key(context.report_context) else { let Some(cache_key) = local_scheduler_affinity_cache_key(context.report_context) else {
+5 -4
View File
@@ -56,10 +56,11 @@ pub(crate) use self::oauth_error::{
}; };
pub(crate) use self::policy::{ pub(crate) use self::policy::{
append_local_failover_policy_to_value, codex_cyber_flag_passthrough_enabled, append_local_failover_policy_to_value, codex_cyber_flag_passthrough_enabled,
cyber_continue_failover_enabled, local_failover_policy_from_report_context, local_failover_policy_from_report_context, local_failover_policy_from_transport,
local_failover_policy_from_transport, resolve_local_failover_policy, resolve_local_failover_policy, responses_websocket_adapter,
responses_websocket_adapter, LocalFailoverPolicy, LocalFailoverRegexRule, routing_execution_policy_from_report_context, LocalFailoverPolicy, LocalFailoverRegexRule,
ResponsesWebSocketAdapter, CYBER_CONTINUE_FAILOVER_CONFIG_KEY, RESPONSES_WEBSOCKET_CONFIG_KEY, ResponsesWebSocketAdapter, RESPONSES_WEBSOCKET_CONFIG_KEY,
ROUTING_EXECUTION_POLICY_REPORT_FIELD,
}; };
pub(crate) use self::recovery::{ pub(crate) use self::recovery::{
analyze_local_failover, analyze_local_transport_error, apply_provider_failure_disposition, analyze_local_failover, analyze_local_transport_error, apply_provider_failure_disposition,
+13 -12
View File
@@ -4,11 +4,13 @@ use aether_contracts::ExecutionPlan;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::debug; use tracing::debug;
use aether_routing_core::RoutingExecutionPolicy;
use crate::provider_transport::GatewayProviderTransportSnapshot; use crate::provider_transport::GatewayProviderTransportSnapshot;
use crate::AppState; use crate::AppState;
pub(crate) const CYBER_CONTINUE_FAILOVER_CONFIG_KEY: &str = "cyber_continue_failover";
pub(crate) const RESPONSES_WEBSOCKET_CONFIG_KEY: &str = "responses_websocket"; pub(crate) const RESPONSES_WEBSOCKET_CONFIG_KEY: &str = "responses_websocket";
pub(crate) const ROUTING_EXECUTION_POLICY_REPORT_FIELD: &str = "routing_execution_policy";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalFailoverPolicy { pub(crate) struct LocalFailoverPolicy {
@@ -50,7 +52,7 @@ pub(crate) struct LocalFailoverRegexRule {
pub(crate) async fn resolve_local_failover_policy( pub(crate) async fn resolve_local_failover_policy(
state: &AppState, state: &AppState,
plan: &ExecutionPlan, plan: &ExecutionPlan,
_report_context: Option<&serde_json::Value>, report_context: Option<&serde_json::Value>,
) -> LocalFailoverPolicy { ) -> LocalFailoverPolicy {
let mut policy = match state let mut policy = match state
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id) .read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
@@ -59,7 +61,8 @@ pub(crate) async fn resolve_local_failover_policy(
Ok(Some(transport)) => local_failover_policy_from_transport(&transport), Ok(Some(transport)) => local_failover_policy_from_transport(&transport),
Ok(None) | Err(_) => LocalFailoverPolicy::default(), Ok(None) | Err(_) => LocalFailoverPolicy::default(),
}; };
let cyber_continue_failover = cyber_continue_failover_enabled(state).await; let cyber_continue_failover = routing_execution_policy_from_report_context(report_context)
.is_some_and(|policy| policy.cyber_continue_failover);
policy.stop_cyber_policy_errors = !cyber_continue_failover; policy.stop_cyber_policy_errors = !cyber_continue_failover;
debug!( debug!(
event_name = "local_failover_policy_loaded", event_name = "local_failover_policy_loaded",
@@ -83,15 +86,13 @@ pub(crate) async fn resolve_local_failover_policy(
policy policy
} }
pub(crate) async fn cyber_continue_failover_enabled(state: &AppState) -> bool { pub(crate) fn routing_execution_policy_from_report_context(
state report_context: Option<&Value>,
.read_system_config_json_value(CYBER_CONTINUE_FAILOVER_CONFIG_KEY) ) -> Option<RoutingExecutionPolicy> {
.await report_context
.ok() .and_then(Value::as_object)
.flatten() .and_then(|object| object.get(ROUTING_EXECUTION_POLICY_REPORT_FIELD))
.as_ref() .and_then(|value| serde_json::from_value(value.clone()).ok())
.and_then(Value::as_bool)
.unwrap_or(false)
} }
pub(crate) fn local_failover_policy_from_transport( pub(crate) fn local_failover_policy_from_transport(
+38 -3
View File
@@ -95,6 +95,7 @@ pub(crate) fn resolve_gateway_static_default_routing_policy(
scheduling_mode: default_policy.scheduling_mode, scheduling_mode: default_policy.scheduling_mode,
keep_priority_on_conversion: default_policy.keep_priority_on_conversion, keep_priority_on_conversion: default_policy.keep_priority_on_conversion,
sticky_key_attempts: default_policy.sticky_key_attempts, sticky_key_attempts: default_policy.sticky_key_attempts,
execution_policy: default_policy.execution_policy,
ranking_overlay: RankingOverlay::default(), ranking_overlay: RankingOverlay::default(),
mutation_plan: MutationPlan::default(), mutation_plan: MutationPlan::default(),
pool_policy_overrides: BTreeMap::new(), pool_policy_overrides: BTreeMap::new(),
@@ -108,8 +109,10 @@ fn static_default_policy_fields(
let Some(object) = config_json.as_object() else { let Some(object) = config_json.as_object() else {
return Ok(None); return Ok(None);
}; };
if !routing_array_field_is_missing_or_empty(object, "allowed_models") // A strategy's default policy applies to every model. Only model policies
|| !routing_array_field_is_missing_or_empty(object, "model_policies") // and rules require the request-context-aware resolver; unknown legacy
// fields (including the removed group allowlist) are intentionally ignored.
if !routing_array_field_is_missing_or_empty(object, "model_policies")
|| !routing_array_field_is_missing_or_empty(object, "rules") || !routing_array_field_is_missing_or_empty(object, "rules")
{ {
return Ok(None); return Ok(None);
@@ -145,15 +148,47 @@ fn static_default_policy_fields(
})?, })?,
None => DEFAULT_STICKY_KEY_ATTEMPTS, None => DEFAULT_STICKY_KEY_ATTEMPTS,
}; };
let enable_cf_heartbeat = routing_bool_field(
default_policy.get("enable_cf_heartbeat"),
"enable_cf_heartbeat",
)?;
// Older strategies stored separate image/text heartbeat flags. Treat
// either legacy flag as enabling the unified CF heartbeat setting while
// allowing newly saved strategies to use only the canonical key.
let legacy_image_heartbeat = routing_bool_field(
default_policy.get("enable_openai_image_sync_heartbeat"),
"enable_openai_image_sync_heartbeat",
)?;
let legacy_text_heartbeat = routing_bool_field(
default_policy.get("enable_standard_text_sync_heartbeat"),
"enable_standard_text_sync_heartbeat",
)?;
let execution_policy = aether_routing_core::RoutingExecutionPolicy {
enable_cf_heartbeat: enable_cf_heartbeat || legacy_image_heartbeat || legacy_text_heartbeat,
cyber_continue_failover: routing_bool_field(
default_policy.get("cyber_continue_failover"),
"cyber_continue_failover",
)?,
};
Ok(Some(RoutingDefaultPolicy { Ok(Some(RoutingDefaultPolicy {
priority_mode, priority_mode,
scheduling_mode, scheduling_mode,
keep_priority_on_conversion, keep_priority_on_conversion,
sticky_key_attempts, sticky_key_attempts,
execution_policy,
})) }))
} }
fn routing_bool_field(value: Option<&Value>, field: &str) -> Result<bool, GatewayError> {
match value {
Some(value) => value
.as_bool()
.ok_or_else(|| invalid_routing_group_config(format!("{field} must be a boolean"))),
None => Ok(false),
}
}
fn routing_array_field_is_missing_or_empty( fn routing_array_field_is_missing_or_empty(
object: &serde_json::Map<String, Value>, object: &serde_json::Map<String, Value>,
key: &str, key: &str,
@@ -201,7 +236,7 @@ mod tests {
"scheduling_mode": "load_balance", "scheduling_mode": "load_balance",
"keep_priority_on_conversion": true "keep_priority_on_conversion": true
}, },
"allowed_models": [], "allowed_models": ["legacy-model"],
"model_policies": [], "model_policies": [],
"rules": [] "rules": []
}); });
@@ -8,6 +8,8 @@ pub(crate) const ROUTING_GROUP_HEADER: &str = "x-aether-scheduler-group";
#[derive(Debug, Error, Clone, PartialEq, Eq)] #[derive(Debug, Error, Clone, PartialEq, Eq)]
pub(crate) enum GatewayRoutingSelectionError { pub(crate) enum GatewayRoutingSelectionError {
#[error("no enabled routing strategy is configured for this request")]
NoDefault,
#[error("routing group was explicitly requested but was not found: {0}")] #[error("routing group was explicitly requested but was not found: {0}")]
NotFound(String), NotFound(String),
#[error("routing group was explicitly requested but is not enabled: {0}")] #[error("routing group was explicitly requested but is not enabled: {0}")]
@@ -239,6 +241,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: false, is_system_default: false,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -287,6 +290,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: true, is_system_default: true,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -322,6 +326,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: false, is_system_default: false,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -444,6 +449,7 @@ mod tests {
description: None, description: None,
enabled: false, enabled: false,
is_system_default: false, is_system_default: false,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -481,6 +487,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: false, is_system_default: false,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -1,7 +1,6 @@
use self::selection::{ use self::selection::{
collect_selectable_candidates, collect_selectable_candidates_with_skip_reasons_and_ordering, collect_selectable_candidates, collect_selectable_candidates_with_skip_reasons_and_ordering,
collect_selectable_enumerated_candidates_with_skip_reasons, collect_selectable_enumerated_candidates_with_skip_reasons,
resolve_preselection_ordering_config,
}; };
use super::config::SchedulerOrderingConfig; use super::config::SchedulerOrderingConfig;
use super::state::SchedulerRuntimeState; use super::state::SchedulerRuntimeState;
@@ -56,8 +55,7 @@ enum RequiredCapabilityMatchMode {
} }
/// `ordering_config` carries the request's routing-policy derived scheduler /// `ordering_config` carries the request's routing-policy derived scheduler
/// config. `None` falls back to the runtime default (system-default routing /// config. Every production scheduling pass must provide this snapshot.
/// group, then legacy system-config keys).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn list_selectable_candidates( pub(crate) async fn list_selectable_candidates(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
@@ -70,7 +68,7 @@ pub(crate) async fn list_selectable_candidates(
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
collect_selectable_candidates( collect_selectable_candidates(
selection_row_source, selection_row_source,
@@ -107,7 +105,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -145,7 +143,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_ope
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
request_operation: Option<&str>, request_operation: Option<&str>,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -180,7 +178,7 @@ pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -188,8 +186,6 @@ pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
), ),
GatewayError, GatewayError,
> { > {
let ordering_config =
resolve_preselection_ordering_config(runtime_state, ordering_config).await?;
let priority_affinity_key = selection::scheduling_priority_affinity_key( let priority_affinity_key = selection::scheduling_priority_affinity_key(
auth_snapshot, auth_snapshot,
client_session_affinity, client_session_affinity,
@@ -220,7 +216,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
Ok( Ok(
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal( list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
@@ -249,7 +245,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> { ) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> {
let normalized_api_format = normalize_api_format(candidate_api_format); let normalized_api_format = normalize_api_format(candidate_api_format);
if normalized_api_format.is_empty() { if normalized_api_format.is_empty() {
@@ -47,7 +47,7 @@ pub(super) fn is_exact_all_skipped_by_auth_limit(
.all(|candidate| is_auth_api_key_concurrency_limit_skip_reason(candidate.skip_reason)) .all(|candidate| is_auth_api_key_concurrency_limit_skip_reason(candidate.skip_reason))
} }
#[cfg_attr(not(test), allow(dead_code))] #[cfg(test)]
pub(super) async fn select_minimal_candidate( pub(super) async fn select_minimal_candidate(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState, runtime_state: &impl SchedulerRuntimeState,
@@ -59,20 +59,8 @@ pub(super) async fn select_minimal_candidate(
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: SchedulerOrderingConfig,
) -> Result<Option<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Option<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let affinity_epoch = runtime_state.scheduler_affinity_epoch();
let ordering_config = runtime_state.read_scheduler_ordering_config().await?;
let affinity_cache_key = build_scheduler_affinity_cache_key(
auth_snapshot,
api_format,
global_model_name,
client_session_affinity,
);
let priority_affinity_key = scheduling_priority_affinity_key(
auth_snapshot,
client_session_affinity,
ordering_config.scheduling_mode,
);
let candidates = enumerate_scheduler_candidates( let candidates = enumerate_scheduler_candidates(
selection_row_source, selection_row_source,
api_format, api_format,
@@ -84,7 +72,7 @@ pub(super) async fn select_minimal_candidate(
None, None,
) )
.await?; .await?;
let selected = collect_selectable_enumerated_candidates_with_skip_reasons( Ok(collect_selectable_enumerated_candidates_with_skip_reasons(
runtime_state, runtime_state,
api_format, api_format,
global_model_name, global_model_name,
@@ -94,25 +82,16 @@ pub(super) async fn select_minimal_candidate(
client_session_affinity, client_session_affinity,
now_unix_secs, now_unix_secs,
ordering_config, ordering_config,
priority_affinity_key, scheduling_priority_affinity_key(
auth_snapshot,
client_session_affinity,
ordering_config.scheduling_mode,
),
) )
.await? .await?
.0 .0
.into_iter() .into_iter()
.next(); .next())
if ordering_config.scheduling_mode == SchedulerSchedulingMode::CacheAffinity
&& has_explicit_session_affinity(client_session_affinity)
{
if let Some(candidate) = selected.as_ref() {
remember_scheduler_affinity(
affinity_cache_key.as_deref(),
runtime_state,
candidate,
Some(affinity_epoch),
);
}
}
Ok(selected)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -127,7 +106,7 @@ pub(super) async fn collect_selectable_candidates(
client_session_affinity: Option<&ClientSessionAffinity>, client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
Ok( Ok(
collect_selectable_candidates_with_skip_reasons_and_ordering( collect_selectable_candidates_with_skip_reasons_and_ordering(
@@ -149,10 +128,8 @@ pub(super) async fn collect_selectable_candidates(
) )
} }
/// Legacy-shaped entrypoint that resolves the ordering config from the
/// runtime state. Prefer `collect_selectable_candidates_with_skip_reasons_and_ordering`
/// and pass the request's routing-policy config explicitly.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(super) async fn collect_selectable_candidates_with_skip_reasons( pub(super) async fn collect_selectable_candidates_with_skip_reasons(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState, runtime_state: &impl SchedulerRuntimeState,
@@ -184,24 +161,11 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
now_unix_secs, now_unix_secs,
enable_model_directives, enable_model_directives,
request_operation, request_operation,
None, SchedulerOrderingConfig::default(),
) )
.await .await
} }
/// Resolve the ordering config for a preselection pass: the routing-policy
/// derived config wins when the caller has one; otherwise fall back to the
/// runtime default (system-default routing group, then legacy keys).
pub(super) async fn resolve_preselection_ordering_config(
runtime_state: &impl SchedulerRuntimeState,
ordering_config: Option<SchedulerOrderingConfig>,
) -> Result<SchedulerOrderingConfig, GatewayError> {
match ordering_config {
Some(config) => Ok(config),
None => runtime_state.read_scheduler_ordering_config().await,
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) async fn collect_selectable_candidates_with_skip_reasons_and_ordering( pub(super) async fn collect_selectable_candidates_with_skip_reasons_and_ordering(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
@@ -215,7 +179,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons_and_ordering
now_unix_secs: u64, now_unix_secs: u64,
enable_model_directives: bool, enable_model_directives: bool,
request_operation: Option<&str>, request_operation: Option<&str>,
ordering_config: Option<SchedulerOrderingConfig>, ordering_config: SchedulerOrderingConfig,
) -> Result< ) -> Result<
( (
Vec<SchedulerMinimalCandidateSelectionCandidate>, Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -223,8 +187,6 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons_and_ordering
), ),
GatewayError, GatewayError,
> { > {
let ordering_config =
resolve_preselection_ordering_config(runtime_state, ordering_config).await?;
let priority_affinity_key = scheduling_priority_affinity_key( let priority_affinity_key = scheduling_priority_affinity_key(
auth_snapshot, auth_snapshot,
client_session_affinity, client_session_affinity,
@@ -23,6 +23,7 @@ use crate::data::candidate_selection::{
read_requested_model_rows, MinimalCandidateSelectionRowSource, read_requested_model_rows, MinimalCandidateSelectionRowSource,
}; };
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
use super::super::affinity::build_scheduler_affinity_cache_key; use super::super::affinity::build_scheduler_affinity_cache_key;
@@ -50,6 +51,7 @@ async fn select_candidate(
client_session_affinity, client_session_affinity,
now_unix_secs, now_unix_secs,
false, false,
SchedulerOrderingConfig::default(),
) )
.await .await
} }
@@ -65,7 +65,7 @@ async fn compatible_required_capability_prefers_matching_keys_without_hard_filte
None, None,
None, None,
100, 100,
None, crate::scheduler::config::SchedulerOrderingConfig::default(),
) )
.await .await
.expect("selection should succeed"); .expect("selection should succeed");
@@ -121,7 +121,7 @@ async fn exclusive_required_capability_keeps_hard_filtering_only_matching_keys()
None, None,
None, None,
100, 100,
None, crate::scheduler::config::SchedulerOrderingConfig::default(),
) )
.await .await
.expect("selection should succeed"); .expect("selection should succeed");
@@ -198,7 +198,7 @@ async fn required_capability_without_model_uses_session_scoped_affinity() {
Some(&auth_snapshot), Some(&auth_snapshot),
Some(&client_session_affinity), Some(&client_session_affinity),
100, 100,
None, crate::scheduler::config::SchedulerOrderingConfig::default(),
) )
.await .await
.expect("selection should succeed"); .expect("selection should succeed");
@@ -276,7 +276,7 @@ async fn required_capability_reports_auth_limit_signal_when_every_model_is_block
Some(&auth_snapshot), Some(&auth_snapshot),
None, None,
100, 100,
None, crate::scheduler::config::SchedulerOrderingConfig::default(),
) )
.await .await
.expect("selection should succeed"); .expect("selection should succeed");
@@ -5,6 +5,7 @@ use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelect
use aether_data::repository::candidates::InMemoryRequestCandidateRepository; use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::quota::InMemoryProviderQuotaRepository; use aether_data::repository::quota::InMemoryProviderQuotaRepository;
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
use aether_data_contracts::repository::candidate_selection::{ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping, StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
}; };
@@ -13,6 +14,9 @@ use aether_data_contracts::repository::candidates::{
}; };
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot; use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupWriteRepository,
};
use aether_scheduler_core::{ClientSessionAffinity, SchedulerMinimalCandidateSelectionCandidate}; use aether_scheduler_core::{ClientSessionAffinity, SchedulerMinimalCandidateSelectionCandidate};
use serde_json::json; use serde_json::json;
@@ -20,6 +24,7 @@ use crate::cache::SchedulerAffinityTarget;
use crate::data::auth::GatewayAuthApiKeySnapshot; use crate::data::auth::GatewayAuthApiKeySnapshot;
use crate::data::candidate_selection::MinimalCandidateSelectionRowSource; use crate::data::candidate_selection::MinimalCandidateSelectionRowSource;
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
use crate::scheduler::config::SchedulerOrderingConfig;
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
use super::super::affinity::build_scheduler_affinity_cache_key; use super::super::affinity::build_scheduler_affinity_cache_key;
@@ -31,6 +36,39 @@ use super::super::selection::{
}; };
use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row}; use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row};
async fn state_with_routing_default_policy(
data_state: GatewayDataState,
default_policy: serde_json::Value,
) -> AppState {
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "selection-test-default".to_string(),
name: "selection-test-default".to_string(),
description: None,
enabled: true,
is_system_default: true,
sort_order: 0,
config_json: json!({"default_policy": default_policy}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("routing strategy should be created");
AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state.with_routing_group_repository_for_tests(repository))
}
async fn ordering_config(state: &AppState) -> SchedulerOrderingConfig {
crate::scheduler::config::read_system_default_routing_ordering_config(state)
.await
.expect("routing strategy should load")
.unwrap_or_default()
}
async fn select_candidate( async fn select_candidate(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &AppState, runtime_state: &AppState,
@@ -40,6 +78,7 @@ async fn select_candidate(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<Option<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Option<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let ordering_config = ordering_config(runtime_state).await;
select_candidate_impl( select_candidate_impl(
selection_row_source, selection_row_source,
runtime_state, runtime_state,
@@ -51,6 +90,7 @@ async fn select_candidate(
None, None,
now_unix_secs, now_unix_secs,
false, false,
ordering_config,
) )
.await .await
} }
@@ -64,6 +104,7 @@ async fn collect_selectable_candidates(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let ordering_config = ordering_config(runtime_state).await;
collect_selectable_candidates_impl( collect_selectable_candidates_impl(
selection_row_source, selection_row_source,
runtime_state, runtime_state,
@@ -75,7 +116,7 @@ async fn collect_selectable_candidates(
None, None,
now_unix_secs, now_unix_secs,
false, false,
None, ordering_config,
) )
.await .await
} }
@@ -289,15 +330,11 @@ async fn selects_by_provider_priority_when_priority_mode_is_provider() {
global_key_first, global_key_first,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"priority_mode": "provider"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"provider_priority_mode".to_string(),
json!("provider"),
)]),
);
let selected = select_candidate( let selected = select_candidate(
state.data.as_ref(), state.data.as_ref(),
@@ -343,15 +380,11 @@ async fn selects_by_global_key_priority_when_priority_mode_is_global_key() {
global_key_first, global_key_first,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"priority_mode": "global_key"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"provider_priority_mode".to_string(),
json!("global_key"),
)]),
);
let selected = select_candidate( let selected = select_candidate(
state.data.as_ref(), state.data.as_ref(),
@@ -415,6 +448,7 @@ async fn scheduler_selection_prefers_required_capability_matches_before_priority
None, None,
100, 100,
false, false,
SchedulerOrderingConfig::default(),
) )
.await .await
.expect("selection should succeed") .expect("selection should succeed")
@@ -450,15 +484,11 @@ async fn fixed_order_ignores_cached_scheduler_affinity_promotion() {
first, second, first, second,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "fixed_order"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("fixed_order"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
state.remember_scheduler_affinity_target( state.remember_scheduler_affinity_target(
@@ -515,15 +545,11 @@ async fn fixed_order_disables_same_priority_affinity_hash_tiebreaker() {
first, second, first, second,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "fixed_order"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("fixed_order"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
let selection = collect_selectable_candidates( let selection = collect_selectable_candidates(
@@ -569,15 +595,11 @@ async fn cache_affinity_promotes_cached_scheduler_affinity_candidate_when_enable
first, second, first, second,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "cache_affinity"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("cache_affinity"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1"); let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
@@ -610,6 +632,7 @@ async fn cache_affinity_promotes_cached_scheduler_affinity_candidate_when_enable
Some(&client_session_affinity), Some(&client_session_affinity),
100, 100,
false, false,
ordering_config(&state).await,
) )
.await .await
.expect("selection should succeed") .expect("selection should succeed")
@@ -645,15 +668,11 @@ async fn cache_affinity_ignores_cached_scheduler_affinity_without_client_session
first, second, first, second,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "cache_affinity"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("cache_affinity"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
state.remember_scheduler_affinity_target( state.remember_scheduler_affinity_target(
@@ -691,15 +710,11 @@ async fn load_balance_selection_does_not_remember_scheduler_affinity() {
row, row,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "load_balance"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("load_balance"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1"); let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cache_key = build_scheduler_affinity_cache_key( let cache_key = build_scheduler_affinity_cache_key(
@@ -721,6 +736,7 @@ async fn load_balance_selection_does_not_remember_scheduler_affinity() {
Some(&client_session_affinity), Some(&client_session_affinity),
100, 100,
false, false,
ordering_config(&state).await,
) )
.await .await
.expect("selection should succeed") .expect("selection should succeed")
@@ -758,15 +774,11 @@ async fn load_balance_ignores_provider_priority_and_cached_affinity() {
first, second, first, second,
])); ]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![])); let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = AppState::new() let state = state_with_routing_default_policy(
.expect("state should build") GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
.with_data_state_for_tests( json!({"scheduling_mode": "load_balance"}),
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas) )
.with_system_config_values_for_tests(vec![( .await;
"scheduling_mode".to_string(),
json!("load_balance"),
)]),
);
let auth_snapshot = sample_auth_snapshot("affinity-key-1"); let auth_snapshot = sample_auth_snapshot("affinity-key-1");
state.remember_scheduler_affinity_target( state.remember_scheduler_affinity_target(
+61 -125
View File
@@ -55,7 +55,7 @@ impl Default for SchedulerOrderingConfig {
impl SchedulerOrderingConfig { impl SchedulerOrderingConfig {
/// Ordering config derived from a resolved routing policy. The policy is /// Ordering config derived from a resolved routing policy. The policy is
/// the single source of truth: no legacy system-config value is merged in. /// the single source of truth for request scheduling.
pub(crate) fn from_routing_policy(policy: &ResolvedRoutingPolicy) -> Self { pub(crate) fn from_routing_policy(policy: &ResolvedRoutingPolicy) -> Self {
Self { Self {
priority_mode: scheduler_priority_mode_from_routing(policy.priority_mode), priority_mode: scheduler_priority_mode_from_routing(policy.priority_mode),
@@ -87,6 +87,7 @@ impl SchedulerOrderingConfig {
}, },
keep_priority_on_conversion: self.keep_priority_on_conversion, keep_priority_on_conversion: self.keep_priority_on_conversion,
sticky_key_attempts: self.sticky_key_attempts, sticky_key_attempts: self.sticky_key_attempts,
execution_policy: aether_routing_core::RoutingExecutionPolicy::default(),
} }
} }
@@ -114,61 +115,6 @@ fn scheduler_scheduling_mode_from_routing(mode: RoutingSchedulingMode) -> Schedu
} }
} }
pub(crate) fn parse_scheduler_priority_mode(
value: Option<&serde_json::Value>,
) -> SchedulerPriorityMode {
match value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
.as_deref()
{
Some("global_key") => SchedulerPriorityMode::GlobalKey,
_ => SchedulerPriorityMode::Provider,
}
}
pub(crate) fn parse_keep_priority_on_conversion(value: Option<&serde_json::Value>) -> bool {
value.and_then(serde_json::Value::as_bool).unwrap_or(false)
}
pub(crate) fn parse_scheduler_scheduling_mode(
value: Option<&serde_json::Value>,
) -> SchedulerSchedulingMode {
match value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
.as_deref()
{
Some("fixed_order") => SchedulerSchedulingMode::FixedOrder,
Some("load_balance") => SchedulerSchedulingMode::LoadBalance,
_ => SchedulerSchedulingMode::CacheAffinity,
}
}
/// Effective scheduler ordering config for requests that carry no resolved
/// routing policy.
///
/// Resolution order:
/// 1. the enabled system-default routing group's `default_policy`;
/// 2. the legacy system-config keys (`provider_priority_mode`,
/// `scheduling_mode`, `keep_priority_on_conversion`).
///
/// Step 2 only exists so deployments that never created a routing group keep
/// their behaviour; once the legacy keys are removed this function collapses
/// to step 1 plus `SchedulerOrderingConfig::default()`.
pub(crate) async fn read_scheduler_ordering_config(
state: &AppState,
) -> Result<SchedulerOrderingConfig, GatewayError> {
if let Some(config) = read_system_default_routing_ordering_config(state).await? {
return Ok(config);
}
read_legacy_scheduler_ordering_config(state).await
}
/// Ordering config from the enabled system-default routing group, if any. /// Ordering config from the enabled system-default routing group, if any.
pub(crate) async fn read_system_default_routing_ordering_config( pub(crate) async fn read_system_default_routing_ordering_config(
state: &AppState, state: &AppState,
@@ -201,38 +147,6 @@ pub(crate) async fn read_system_default_routing_ordering_config(
))) )))
} }
/// Legacy system-config based ordering config. Kept only as a migration
/// fallback; see `read_scheduler_ordering_config`.
pub(crate) async fn read_legacy_scheduler_ordering_config(
state: &AppState,
) -> Result<SchedulerOrderingConfig, GatewayError> {
let priority_mode = parse_scheduler_priority_mode(
state
.read_system_config_json_value("provider_priority_mode")
.await?
.as_ref(),
);
let scheduling_mode = parse_scheduler_scheduling_mode(
state
.read_system_config_json_value("scheduling_mode")
.await?
.as_ref(),
);
let keep_priority_on_conversion = parse_keep_priority_on_conversion(
state
.read_system_config_json_value("keep_priority_on_conversion")
.await?
.as_ref(),
);
Ok(SchedulerOrderingConfig {
priority_mode,
scheduling_mode,
keep_priority_on_conversion,
// Legacy config never carried a sticky-key setting; use the routing default.
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
@@ -247,14 +161,6 @@ mod tests {
use super::*; use super::*;
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
fn legacy_values() -> [(String, serde_json::Value); 3] {
[
("provider_priority_mode".to_string(), json!("global_key")),
("scheduling_mode".to_string(), json!("load_balance")),
("keep_priority_on_conversion".to_string(), json!(true)),
]
}
async fn create_system_default( async fn create_system_default(
repository: &InMemoryRoutingGroupRepository, repository: &InMemoryRoutingGroupRepository,
enabled: bool, enabled: bool,
@@ -267,6 +173,7 @@ mod tests {
description: None, description: None,
enabled, enabled,
is_system_default: true, is_system_default: true,
sort_order: 0,
config_json, config_json,
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -278,7 +185,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn system_default_routing_group_overrides_legacy_keys() { async fn system_default_routing_group_exposes_strategy_ordering() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default()); let repository = Arc::new(InMemoryRoutingGroupRepository::default());
create_system_default( create_system_default(
&repository, &repository,
@@ -293,12 +200,13 @@ mod tests {
) )
.await; .await;
let state = AppState::new().unwrap().with_data_state_for_tests( let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled() GatewayDataState::disabled().with_routing_group_repository_for_tests(repository),
.with_system_config_values_for_tests(legacy_values())
.with_routing_group_repository_for_tests(repository),
); );
let config = read_scheduler_ordering_config(&state).await.unwrap(); let config = read_system_default_routing_ordering_config(&state)
.await
.unwrap()
.unwrap();
assert_eq!(config.priority_mode, SchedulerPriorityMode::Provider); assert_eq!(config.priority_mode, SchedulerPriorityMode::Provider);
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::FixedOrder); assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::FixedOrder);
@@ -310,18 +218,19 @@ mod tests {
let repository = Arc::new(InMemoryRoutingGroupRepository::default()); let repository = Arc::new(InMemoryRoutingGroupRepository::default());
create_system_default(&repository, true, json!({})).await; create_system_default(&repository, true, json!({})).await;
let state = AppState::new().unwrap().with_data_state_for_tests( let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled() GatewayDataState::disabled().with_routing_group_repository_for_tests(repository),
.with_system_config_values_for_tests(legacy_values())
.with_routing_group_repository_for_tests(repository),
); );
let config = read_scheduler_ordering_config(&state).await.unwrap(); let config = read_system_default_routing_ordering_config(&state)
.await
.unwrap()
.unwrap();
assert_eq!(config, SchedulerOrderingConfig::default()); assert_eq!(config, SchedulerOrderingConfig::default());
} }
#[tokio::test] #[tokio::test]
async fn disabled_or_missing_system_default_group_falls_back_to_legacy_keys() { async fn disabled_or_missing_system_default_group_uses_routing_defaults() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default()); let repository = Arc::new(InMemoryRoutingGroupRepository::default());
create_system_default( create_system_default(
&repository, &repository,
@@ -330,28 +239,25 @@ mod tests {
) )
.await; .await;
let with_disabled_group = AppState::new().unwrap().with_data_state_for_tests( let with_disabled_group = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled() GatewayDataState::disabled().with_routing_group_repository_for_tests(repository),
.with_system_config_values_for_tests(legacy_values())
.with_routing_group_repository_for_tests(repository),
);
let without_repository = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled().with_system_config_values_for_tests(legacy_values()),
); );
let without_repository = AppState::new()
.unwrap()
.with_data_state_for_tests(GatewayDataState::disabled());
for state in [with_disabled_group, without_repository] { for state in [with_disabled_group, without_repository] {
let config = read_scheduler_ordering_config(&state).await.unwrap(); let config = read_system_default_routing_ordering_config(&state)
assert_eq!(config.priority_mode, SchedulerPriorityMode::GlobalKey); .await
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::LoadBalance); .unwrap();
assert!(config.keep_priority_on_conversion); assert!(config.is_none());
} }
} }
#[tokio::test] #[tokio::test]
async fn bootstrap_creates_system_default_group_from_legacy_keys_once() { async fn bootstrap_creates_system_default_group_from_routing_defaults_once() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default()); let repository = Arc::new(InMemoryRoutingGroupRepository::default());
let state = AppState::new().unwrap().with_data_state_for_tests( let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled() GatewayDataState::disabled()
.with_system_config_values_for_tests(legacy_values())
.with_routing_group_repository_for_tests(repository.clone()), .with_routing_group_repository_for_tests(repository.clone()),
); );
@@ -365,9 +271,9 @@ mod tests {
assert_eq!( assert_eq!(
created.config_json["default_policy"], created.config_json["default_policy"],
json!({ json!({
"priority_mode": "global_key", "priority_mode": "provider",
"scheduling_mode": "load_balance", "scheduling_mode": "cache_affinity",
"keep_priority_on_conversion": true, "keep_priority_on_conversion": false,
"sticky_key_attempts": DEFAULT_STICKY_KEY_ATTEMPTS "sticky_key_attempts": DEFAULT_STICKY_KEY_ATTEMPTS
}) })
); );
@@ -386,9 +292,39 @@ mod tests {
Some(created.id) Some(created.id)
); );
let config = read_scheduler_ordering_config(&state).await.unwrap(); let config = read_system_default_routing_ordering_config(&state)
assert_eq!(config.priority_mode, SchedulerPriorityMode::GlobalKey); .await
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::LoadBalance); .unwrap()
assert!(config.keep_priority_on_conversion); .unwrap();
assert_eq!(config, SchedulerOrderingConfig::default());
}
#[tokio::test]
async fn bootstrap_does_not_migrate_legacy_scheduler_keys() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::disabled()
.with_system_config_values_for_tests([
("provider_priority_mode".to_string(), json!("global_key")),
("scheduling_mode".to_string(), json!("load_balance")),
("keep_priority_on_conversion".to_string(), json!(true)),
])
.with_routing_group_repository_for_tests(repository),
);
let created = state
.ensure_system_default_routing_group_inner()
.await
.unwrap()
.expect("bootstrap should create the strategy");
assert_eq!(
created.config_json["default_policy"],
json!({
"priority_mode": "provider",
"scheduling_mode": "cache_affinity",
"keep_priority_on_conversion": false,
"sticky_key_attempts": DEFAULT_STICKY_KEY_ATTEMPTS
})
);
} }
} }
@@ -10,8 +10,6 @@ use async_trait::async_trait;
use crate::GatewayError; use crate::GatewayError;
use super::config::SchedulerOrderingConfig;
#[async_trait] #[async_trait]
pub(crate) trait SchedulerRuntimeState { pub(crate) trait SchedulerRuntimeState {
async fn read_provider_quota_snapshot( async fn read_provider_quota_snapshot(
@@ -60,7 +58,4 @@ pub(crate) trait SchedulerRuntimeState {
max_entries: usize, max_entries: usize,
expected_epoch: Option<u64>, expected_epoch: Option<u64>,
) -> bool; ) -> bool;
async fn read_scheduler_ordering_config(&self)
-> Result<SchedulerOrderingConfig, GatewayError>;
} }
+7 -11
View File
@@ -79,12 +79,7 @@ const SYSTEM_CONFIG_CACHE_TTL: Duration = Duration::from_secs(30);
// five minutes of total age. Direct database edits that bypass AppState // five minutes of total age. Direct database edits that bypass AppState
// invalidation can therefore take at most this bounded interval to appear. // invalidation can therefore take at most this bounded interval to appear.
const SYSTEM_CONFIG_CACHE_MAX_STALENESS: Duration = Duration::from_secs(5 * 60); const SYSTEM_CONFIG_CACHE_MAX_STALENESS: Duration = Duration::from_secs(5 * 60);
const SCHEDULER_AFFECTING_SYSTEM_CONFIG_KEYS: &[&str] = &[ const SCHEDULER_AFFECTING_SYSTEM_CONFIG_KEYS: &[&str] = &["enable_format_conversion"];
"enable_format_conversion",
"keep_priority_on_conversion",
"provider_priority_mode",
"scheduling_mode",
];
const AUTH_AFFECTING_SYSTEM_CONFIG_KEYS: &[&str] = &[ const AUTH_AFFECTING_SYSTEM_CONFIG_KEYS: &[&str] = &[
crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY, crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY,
crate::constants::ANTIGRAVITY_BEARER_BRIDGE_CONFIG_KEY, crate::constants::ANTIGRAVITY_BEARER_BRIDGE_CONFIG_KEY,
@@ -4350,12 +4345,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn system_config_entry_write_refreshes_cache_and_scheduler_affinity_for_routing_keys() { async fn system_config_entry_write_refreshes_cache_and_scheduler_affinity_for_format_conversion(
) {
let state = AppState::new() let state = AppState::new()
.expect("app state should build") .expect("app state should build")
.with_data_state_for_tests( .with_data_state_for_tests(
GatewayDataState::disabled().with_system_config_values_for_tests([( GatewayDataState::disabled().with_system_config_values_for_tests([(
"keep_priority_on_conversion".to_string(), "enable_format_conversion".to_string(),
json!(false), json!(false),
)]), )]),
); );
@@ -4364,7 +4360,7 @@ mod tests {
assert_eq!( assert_eq!(
state state
.read_system_config_json_value("keep_priority_on_conversion") .read_system_config_json_value("enable_format_conversion")
.await .await
.expect("system config read should succeed"), .expect("system config read should succeed"),
Some(json!(false)) Some(json!(false))
@@ -4385,13 +4381,13 @@ mod tests {
let initial_epoch = state.scheduler_affinity_epoch(); let initial_epoch = state.scheduler_affinity_epoch();
state state
.upsert_system_config_entry("keep_priority_on_conversion", &json!(true), None) .upsert_system_config_entry("enable_format_conversion", &json!(true), None)
.await .await
.expect("admin config write should succeed"); .expect("admin config write should succeed");
assert_eq!( assert_eq!(
state state
.read_system_config_json_value("keep_priority_on_conversion") .read_system_config_json_value("enable_format_conversion")
.await .await
.expect("system config read should use refreshed cache"), .expect("system config read should use refreshed cache"),
Some(json!(true)) Some(json!(true))
@@ -680,10 +680,4 @@ impl SchedulerRuntimeState for AppState {
expected_epoch, expected_epoch,
) )
} }
async fn read_scheduler_ordering_config(
&self,
) -> Result<crate::scheduler::config::SchedulerOrderingConfig, GatewayError> {
crate::scheduler::config::read_scheduler_ordering_config(self).await
}
} }
@@ -15,10 +15,9 @@ const BOOTSTRAP_SYSTEM_DEFAULT_ROUTING_GROUP_NAME: &str = "system-default";
impl AppState { impl AppState {
/// Make sure an enabled system-default routing group exists. /// Make sure an enabled system-default routing group exists.
/// ///
/// When none exists, one is created from the legacy scheduler system-config /// When none exists, one is created from the routing defaults.
/// keys so that removing those keys later does not change behaviour. Returns /// Returns the created group, or `None` when nothing had to be created (no
/// the created group, or `None` when nothing had to be created (no routing /// routing storage, no writer, or a system default already exists).
/// storage, no writer, or a system default already exists).
pub async fn ensure_system_default_routing_group( pub async fn ensure_system_default_routing_group(
&self, &self,
) -> Result<Option<StoredRoutingGroup>, std::io::Error> { ) -> Result<Option<StoredRoutingGroup>, std::io::Error> {
@@ -44,16 +43,12 @@ impl AppState {
warn!( warn!(
event_name = "routing_system_default_bootstrap_skipped", event_name = "routing_system_default_bootstrap_skipped",
log_type = "event", log_type = "event",
"no system default routing group exists and routing storage is read-only; scheduler falls back to legacy system config" "no system default routing group exists and routing storage is read-only; scheduler uses routing defaults"
); );
return Ok(None); return Ok(None);
} }
let legacy = crate::scheduler::config::read_legacy_scheduler_ordering_config(self).await?; let config = RoutingGroupConfig::default();
let config = RoutingGroupConfig {
default_policy: legacy.to_routing_default_policy(),
..RoutingGroupConfig::default()
};
let config_json = serde_json::to_value(config) let config_json = serde_json::to_value(config)
.map_err(|err| GatewayError::Internal(format!("serialize routing config: {err}")))?; .map_err(|err| GatewayError::Internal(format!("serialize routing config: {err}")))?;
@@ -75,9 +70,10 @@ impl AppState {
self.create_routing_group(CreateRoutingGroupRecord { self.create_routing_group(CreateRoutingGroupRecord {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name, name,
description: Some("自动从旧版调度配置迁移生成的系统默认策略".to_string()), description: Some("系统默认调度策略".to_string()),
enabled: true, enabled: true,
is_system_default: true, is_system_default: true,
sort_order: 0,
config_json, config_json,
version: 1, version: 1,
created_at: now, created_at: now,
+1 -28
View File
@@ -1725,8 +1725,6 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"proxy_node_metrics_cleanup_batch_size" => Some(json!(5000)), "proxy_node_metrics_cleanup_batch_size" => Some(json!(5000)),
"enable_provider_checkin" => Some(json!(true)), "enable_provider_checkin" => Some(json!(true)),
"provider_checkin_time" => Some(json!("01:05")), "provider_checkin_time" => Some(json!("01:05")),
"provider_priority_mode" => Some(json!("provider")),
"scheduling_mode" => Some(json!("cache_affinity")),
"auto_delete_expired_keys" => Some(json!(false)), "auto_delete_expired_keys" => Some(json!(false)),
"turnstile_enabled" => Some(json!(false)), "turnstile_enabled" => Some(json!(false)),
"turnstile_site_key" => Some(serde_json::Value::Null), "turnstile_site_key" => Some(serde_json::Value::Null),
@@ -1754,10 +1752,8 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"email_suffix_mode" => Some(json!("none")), "email_suffix_mode" => Some(json!("none")),
"email_suffix_list" => Some(json!([])), "email_suffix_list" => Some(json!([])),
"enable_format_conversion" => Some(json!(false)), "enable_format_conversion" => Some(json!(false)),
"cyber_continue_failover" => Some(json!(false)),
"enable_model_directives" => Some(json!(false)), "enable_model_directives" => Some(json!(false)),
"model_directives" => Some(aether_ai_formats::default_model_directives_config()), "model_directives" => Some(aether_ai_formats::default_model_directives_config()),
"keep_priority_on_conversion" => Some(json!(false)),
"audit_log_retention_days" => Some(json!(30)), "audit_log_retention_days" => Some(json!(30)),
"enable_db_maintenance" => Some(json!(true)), "enable_db_maintenance" => Some(json!(true)),
"system_proxy_node_id" => Some(serde_json::Value::Null), "system_proxy_node_id" => Some(serde_json::Value::Null),
@@ -2236,8 +2232,7 @@ pub fn parse_admin_system_config_update(
} }
match normalized_key.as_str() { match normalized_key.as_str() {
"cyber_continue_failover" "enable_model_directives"
| "enable_model_directives"
| "module.important_notification.enabled" | "module.important_notification.enabled"
| "module.important_notification.email_enabled" | "module.important_notification.email_enabled"
| "module.server_chan_push.enabled" | "module.server_chan_push.enabled"
@@ -3508,28 +3503,6 @@ mod tests {
); );
} }
#[test]
fn cyber_continue_failover_defaults_to_disabled() {
assert_eq!(
admin_system_config_default_value("cyber_continue_failover"),
Some(json!(false))
);
}
#[test]
fn cyber_continue_failover_update_requires_a_boolean() {
let update =
parse_admin_system_config_update("cyber_continue_failover", br#"{"value":true}"#)
.expect("boolean Cyber failover setting should parse");
assert_eq!(update.value, json!(true));
assert!(parse_admin_system_config_update(
"cyber_continue_failover",
br#"{"value":"true"}"#,
)
.is_err());
}
#[test] #[test]
fn model_directives_update_accepts_legacy_and_current_config_shapes() { fn model_directives_update_accepts_legacy_and_current_config_shapes() {
for body in [ for body in [
@@ -0,0 +1,3 @@
ALTER TABLE routing_groups
ADD COLUMN sort_order BIGINT NOT NULL DEFAULT 0,
ADD KEY routing_groups_enabled_sort_idx (enabled, sort_order, name, id);
@@ -15,6 +15,7 @@ SELECT
description, description,
enabled, enabled,
is_system_default, is_system_default,
sort_order,
config_json, config_json,
version, version,
created_at, created_at,
@@ -61,10 +62,12 @@ impl MysqlRoutingGroupRepository {
#[async_trait] #[async_trait]
impl RoutingGroupReadRepository for MysqlRoutingGroupRepository { impl RoutingGroupReadRepository for MysqlRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> { async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC")) let rows = sqlx::query(&format!(
.fetch_all(&self.pool) "{ROUTING_GROUP_SELECT} ORDER BY enabled DESC, sort_order ASC, name ASC, id ASC"
.await ))
.map_sql_err()?; .fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect() rows.iter().map(map_group_row).collect()
} }
@@ -173,10 +176,10 @@ impl RoutingGroupWriteRepository for MysqlRoutingGroupRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO routing_groups ( INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json, id, name, description, enabled, is_system_default, sort_order, config_json,
version, created_at, updated_at, published_at version, created_at, updated_at, published_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#, "#,
) )
.bind(&group.id) .bind(&group.id)
@@ -184,6 +187,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(json_to_string( .bind(json_to_string(
&group.config_json, &group.config_json,
"routing_groups.config_json", "routing_groups.config_json",
@@ -241,6 +245,7 @@ SET name = ?,
description = ?, description = ?,
enabled = ?, enabled = ?,
is_system_default = ?, is_system_default = ?,
sort_order = ?,
config_json = ?, config_json = ?,
version = ?, version = ?,
updated_at = ?, updated_at = ?,
@@ -252,6 +257,7 @@ WHERE id = ?
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(json_to_string( .bind(json_to_string(
&group.config_json, &group.config_json,
"routing_groups.config_json", "routing_groups.config_json",
@@ -460,6 +466,7 @@ fn map_group_row(row: &MySqlRow) -> Result<StoredRoutingGroup, DataLayerError> {
description: row.try_get("description").map_sql_err()?, description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?, enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?, is_system_default: row.try_get("is_system_default").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
config_json: json_from_string( config_json: json_from_string(
row.try_get("config_json").map_sql_err()?, row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json", "routing_groups.config_json",
@@ -79,4 +79,4 @@ BEGIN
END IF; END IF;
END $$; END $$;
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON public.routing_group_versions USING btree (group_id); ON public.routing_group_versions USING btree (group_id);
@@ -0,0 +1,5 @@
ALTER TABLE public.routing_groups
ADD COLUMN sort_order bigint NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS routing_groups_enabled_sort_idx
ON public.routing_groups (enabled DESC, sort_order, name, id);
@@ -22,6 +22,7 @@ SELECT
description, description,
enabled, enabled,
is_system_default, is_system_default,
sort_order,
config_json, config_json,
version, version,
created_at, created_at,
@@ -68,7 +69,9 @@ impl PostgresRoutingGroupRepository {
#[async_trait] #[async_trait]
impl RoutingGroupReadRepository for PostgresRoutingGroupRepository { impl RoutingGroupReadRepository for PostgresRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> { async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let sql = format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC"); let sql = format!(
"{ROUTING_GROUP_SELECT} ORDER BY enabled DESC, sort_order ASC, name ASC, id ASC"
);
let mut rows = sqlx::query(&sql).fetch(&self.pool); let mut rows = sqlx::query(&sql).fetch(&self.pool);
let mut groups = Vec::new(); let mut groups = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? { while let Some(row) = rows.try_next().await.map_postgres_err()? {
@@ -178,10 +181,10 @@ impl RoutingGroupWriteRepository for PostgresRoutingGroupRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO routing_groups ( INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json, id, name, description, enabled, is_system_default, sort_order, config_json,
version, created_at, updated_at, published_at version, created_at, updated_at, published_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"#, "#,
) )
.bind(&group.id) .bind(&group.id)
@@ -189,6 +192,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(&group.config_json) .bind(&group.config_json)
.bind(group.version) .bind(group.version)
.bind(group.created_at) .bind(group.created_at)
@@ -238,10 +242,11 @@ SET name = $2,
description = $3, description = $3,
enabled = $4, enabled = $4,
is_system_default = $5, is_system_default = $5,
config_json = $6, sort_order = $6,
version = $7, config_json = $7,
updated_at = $8, version = $8,
published_at = $9 updated_at = $9,
published_at = $10
WHERE id = $1 WHERE id = $1
"#, "#,
) )
@@ -250,6 +255,7 @@ WHERE id = $1
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(&group.config_json) .bind(&group.config_json)
.bind(group.version) .bind(group.version)
.bind(group.updated_at) .bind(group.updated_at)
@@ -441,6 +447,7 @@ fn map_group_row(row: &PgRow) -> Result<StoredRoutingGroup, DataLayerError> {
description: row.try_get("description").map_postgres_err()?, description: row.try_get("description").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?, enabled: row.try_get("enabled").map_postgres_err()?,
is_system_default: row.try_get("is_system_default").map_postgres_err()?, is_system_default: row.try_get("is_system_default").map_postgres_err()?,
sort_order: row.try_get("sort_order").map_postgres_err()?,
config_json: row.try_get("config_json").map_postgres_err()?, config_json: row.try_get("config_json").map_postgres_err()?,
version: row.try_get("version").map_postgres_err()?, version: row.try_get("version").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?, created_at: row.try_get("created_at").map_postgres_err()?,
@@ -0,0 +1,4 @@
ALTER TABLE routing_groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS routing_groups_enabled_sort_idx
ON routing_groups (enabled, sort_order, name, id);
@@ -15,6 +15,7 @@ SELECT
description, description,
enabled, enabled,
is_system_default, is_system_default,
sort_order,
config_json, config_json,
version, version,
created_at, created_at,
@@ -61,10 +62,12 @@ impl SqliteRoutingGroupRepository {
#[async_trait] #[async_trait]
impl RoutingGroupReadRepository for SqliteRoutingGroupRepository { impl RoutingGroupReadRepository for SqliteRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> { async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC")) let rows = sqlx::query(&format!(
.fetch_all(&self.pool) "{ROUTING_GROUP_SELECT} ORDER BY enabled DESC, sort_order ASC, name ASC, id ASC"
.await ))
.map_sql_err()?; .fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect() rows.iter().map(map_group_row).collect()
} }
@@ -168,10 +171,10 @@ impl RoutingGroupWriteRepository for SqliteRoutingGroupRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO routing_groups ( INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json, id, name, description, enabled, is_system_default, sort_order, config_json,
version, created_at, updated_at, published_at version, created_at, updated_at, published_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#, "#,
) )
.bind(&group.id) .bind(&group.id)
@@ -179,6 +182,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(json_to_string( .bind(json_to_string(
&group.config_json, &group.config_json,
"routing_groups.config_json", "routing_groups.config_json",
@@ -229,6 +233,7 @@ SET name = ?,
description = ?, description = ?,
enabled = ?, enabled = ?,
is_system_default = ?, is_system_default = ?,
sort_order = ?,
config_json = ?, config_json = ?,
version = ?, version = ?,
updated_at = ?, updated_at = ?,
@@ -240,6 +245,7 @@ WHERE id = ?
.bind(&group.description) .bind(&group.description)
.bind(group.enabled) .bind(group.enabled)
.bind(group.is_system_default) .bind(group.is_system_default)
.bind(group.sort_order)
.bind(json_to_string( .bind(json_to_string(
&group.config_json, &group.config_json,
"routing_groups.config_json", "routing_groups.config_json",
@@ -438,6 +444,7 @@ fn map_group_row(row: &SqliteRow) -> Result<StoredRoutingGroup, DataLayerError>
description: row.try_get("description").map_sql_err()?, description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?, enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?, is_system_default: row.try_get("is_system_default").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
config_json: json_from_string( config_json: json_from_string(
row.try_get("config_json").map_sql_err()?, row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json", "routing_groups.config_json",
@@ -514,6 +521,7 @@ mod tests {
description: Some("initial".to_string()), description: Some("initial".to_string()),
enabled: true, enabled: true,
is_system_default: true, is_system_default: true,
sort_order: 0,
config_json: json!({"allowed_models": ["gpt-*"]}), config_json: json!({"allowed_models": ["gpt-*"]}),
version: 1, version: 1,
created_at: 10, created_at: 10,
@@ -790,6 +798,7 @@ SET is_default = 1,
description: None, description: None,
enabled: true, enabled: true,
is_system_default, is_system_default,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -10,6 +10,9 @@ pub struct StoredRoutingGroup {
pub description: Option<String>, pub description: Option<String>,
pub enabled: bool, pub enabled: bool,
pub is_system_default: bool, pub is_system_default: bool,
/// Stable administrator-defined display order. This is intentionally not
/// consulted by request routing or candidate selection.
pub sort_order: i64,
pub config_json: Value, pub config_json: Value,
pub version: i64, pub version: i64,
pub created_at: i64, pub created_at: i64,
@@ -28,6 +31,7 @@ impl StoredRoutingGroup {
description: record.description, description: record.description,
enabled: record.enabled, enabled: record.enabled,
is_system_default: record.is_system_default, is_system_default: record.is_system_default,
sort_order: record.sort_order.max(0),
config_json: record.config_json, config_json: record.config_json,
version: record.version.max(1), version: record.version.max(1),
created_at: record.created_at, created_at: record.created_at,
@@ -44,6 +48,7 @@ pub struct CreateRoutingGroupRecord {
pub description: Option<String>, pub description: Option<String>,
pub enabled: bool, pub enabled: bool,
pub is_system_default: bool, pub is_system_default: bool,
pub sort_order: i64,
pub config_json: Value, pub config_json: Value,
pub version: i64, pub version: i64,
pub created_at: i64, pub created_at: i64,
@@ -57,6 +62,7 @@ pub struct UpdateRoutingGroupRecord {
pub description: Option<Option<String>>, pub description: Option<Option<String>>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub is_system_default: Option<bool>, pub is_system_default: Option<bool>,
pub sort_order: Option<i64>,
pub config_json: Option<Value>, pub config_json: Option<Value>,
pub version: Option<i64>, pub version: Option<i64>,
pub updated_at: i64, pub updated_at: i64,
@@ -255,6 +261,9 @@ pub fn apply_group_patch(
if let Some(is_system_default) = patch.is_system_default { if let Some(is_system_default) = patch.is_system_default {
group.is_system_default = is_system_default; group.is_system_default = is_system_default;
} }
if let Some(sort_order) = patch.sort_order {
group.sort_order = sort_order.max(0);
}
if let Some(config_json) = patch.config_json { if let Some(config_json) = patch.config_json {
if !config_json.is_object() { if !config_json.is_object() {
return Err(crate::DataLayerError::InvalidInput( return Err(crate::DataLayerError::InvalidInput(
@@ -85,4 +85,4 @@ BEGIN
END IF; END IF;
END $$; END $$;
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON public.routing_group_versions USING btree (group_id); ON public.routing_group_versions USING btree (group_id);
@@ -391,6 +391,7 @@ CREATE TABLE IF NOT EXISTS routing_groups (
`description` LONGTEXT, `description` LONGTEXT,
`enabled` TINYINT(1) NOT NULL DEFAULT 1, `enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_system_default` TINYINT(1) NOT NULL DEFAULT 0, `is_system_default` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` BIGINT NOT NULL DEFAULT 0,
`config_json` JSON NOT NULL, `config_json` JSON NOT NULL,
`version` BIGINT NOT NULL DEFAULT 1, `version` BIGINT NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL, `created_at` BIGINT NOT NULL,
@@ -398,7 +399,8 @@ CREATE TABLE IF NOT EXISTS routing_groups (
`published_at` BIGINT, `published_at` BIGINT,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY routing_groups_name_key (`name`), UNIQUE KEY routing_groups_name_key (`name`),
KEY routing_groups_system_default_idx (`is_system_default`, `enabled`) KEY routing_groups_system_default_idx (`is_system_default`, `enabled`),
KEY routing_groups_enabled_sort_idx (`enabled`, `sort_order`, `name`, `id`)
); );
CREATE TABLE IF NOT EXISTS routing_group_bindings ( CREATE TABLE IF NOT EXISTS routing_group_bindings (
@@ -404,6 +404,7 @@ CREATE TABLE IF NOT EXISTS public.routing_groups (
description text, description text,
enabled boolean DEFAULT true NOT NULL, enabled boolean DEFAULT true NOT NULL,
is_system_default boolean DEFAULT false NOT NULL, is_system_default boolean DEFAULT false NOT NULL,
sort_order bigint DEFAULT 0 NOT NULL,
config_json jsonb NOT NULL, config_json jsonb NOT NULL,
version bigint DEFAULT 1 NOT NULL, version bigint DEFAULT 1 NOT NULL,
created_at bigint NOT NULL, created_at bigint NOT NULL,
@@ -414,6 +415,7 @@ CREATE TABLE IF NOT EXISTS public.routing_groups (
ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_pkey PRIMARY KEY (id); ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_name_key UNIQUE (name); ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_name_key UNIQUE (name);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON public.routing_groups USING btree (is_system_default, enabled); CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON public.routing_groups USING btree (is_system_default, enabled);
CREATE INDEX IF NOT EXISTS routing_groups_enabled_sort_idx ON public.routing_groups USING btree (enabled, sort_order, name, id);
CREATE TABLE IF NOT EXISTS public.routing_group_bindings ( CREATE TABLE IF NOT EXISTS public.routing_group_bindings (
id character varying(64) NOT NULL, id character varying(64) NOT NULL,
@@ -378,6 +378,7 @@ CREATE TABLE IF NOT EXISTS routing_groups (
description TEXT, description TEXT,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
is_system_default INTEGER NOT NULL DEFAULT 0, is_system_default INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL, config_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1, version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
@@ -386,6 +387,7 @@ CREATE TABLE IF NOT EXISTS routing_groups (
UNIQUE (name) UNIQUE (name)
); );
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON routing_groups (is_system_default, enabled); CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON routing_groups (is_system_default, enabled);
CREATE INDEX IF NOT EXISTS routing_groups_enabled_sort_idx ON routing_groups (enabled, sort_order, name, id);
CREATE TABLE IF NOT EXISTS routing_group_bindings ( CREATE TABLE IF NOT EXISTS routing_group_bindings (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
@@ -1733,6 +1733,11 @@ name = "is_system_default"
type = "bool" type = "bool"
default = false default = false
[[table.routing_groups.columns]]
name = "sort_order"
type = "int64"
default = 0
[[table.routing_groups.columns]] [[table.routing_groups.columns]]
name = "config_json" name = "config_json"
type = "json" type = "json"
@@ -1763,6 +1768,10 @@ columns = ["name"]
name = "routing_groups_system_default_idx" name = "routing_groups_system_default_idx"
columns = ["is_system_default", "enabled"] columns = ["is_system_default", "enabled"]
[[table.routing_groups.indexes]]
name = "routing_groups_enabled_sort_idx"
columns = ["enabled", "sort_order", "name", "id"]
[table.routing_group_bindings] [table.routing_group_bindings]
domain = "provider_catalog" domain = "provider_catalog"
order = 111 order = 111
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use sqlx::{ use sqlx::{
migrate::{Migrate, MigrateError, Migrator}, migrate::{Migrate, MigrateError, Migrator},
query, Connection, MySqlConnection, Row, query, query_scalar, Connection, MySqlConnection, Row,
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -11,6 +11,7 @@ use crate::driver::mysql::MysqlPool;
static BACKFILL_MIGRATOR: Migrator = sqlx::migrate!("./backfills/mysql"); static BACKFILL_MIGRATOR: Migrator = sqlx::migrate!("./backfills/mysql");
const SCHEMA_BACKFILLS_TABLE_EXISTS_SQL: &str = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'schema_backfills'";
const ENSURE_SCHEMA_BACKFILLS_TABLE_SQL: &str = r#" const ENSURE_SCHEMA_BACKFILLS_TABLE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS schema_backfills ( CREATE TABLE IF NOT EXISTS schema_backfills (
version BIGINT NOT NULL, version BIGINT NOT NULL,
@@ -161,12 +162,21 @@ async fn run_backfills_locked(conn: &mut MySqlConnection) -> Result<(), MigrateE
async fn pending_backfills_locked( async fn pending_backfills_locked(
conn: &mut MySqlConnection, conn: &mut MySqlConnection,
) -> Result<Vec<PendingBackfillInfo>, MigrateError> { ) -> Result<Vec<PendingBackfillInfo>, MigrateError> {
ensure_schema_backfills_table(conn).await?; if !schema_backfills_table_exists(conn).await? {
return Ok(pending_backfills_from_applied(&[]));
}
let applied_backfills = list_applied_backfills(conn).await?; let applied_backfills = list_applied_backfills(conn).await?;
validate_applied_backfills(&applied_backfills)?; validate_applied_backfills(&applied_backfills)?;
Ok(pending_backfills_from_applied(&applied_backfills)) Ok(pending_backfills_from_applied(&applied_backfills))
} }
async fn schema_backfills_table_exists(conn: &mut MySqlConnection) -> Result<bool, MigrateError> {
let total: i64 = query_scalar(SCHEMA_BACKFILLS_TABLE_EXISTS_SQL)
.fetch_one(&mut *conn)
.await?;
Ok(total > 0)
}
async fn ensure_schema_backfills_table(conn: &mut MySqlConnection) -> Result<(), MigrateError> { async fn ensure_schema_backfills_table(conn: &mut MySqlConnection) -> Result<(), MigrateError> {
query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL) query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL)
.execute(&mut *conn) .execute(&mut *conn)
@@ -155,17 +155,23 @@ async fn run_backfills_locked(conn: &mut PgConnection) -> Result<(), MigrateErro
async fn pending_backfills_locked( async fn pending_backfills_locked(
conn: &mut PgConnection, conn: &mut PgConnection,
) -> Result<Vec<PendingBackfillInfo>, MigrateError> { ) -> Result<Vec<PendingBackfillInfo>, MigrateError> {
ensure_schema_backfills_table(conn).await?; if !schema_backfills_table_exists(conn).await? {
return Ok(pending_backfills_from_applied(&[]));
}
let applied_backfills = list_applied_backfills(conn).await?; let applied_backfills = list_applied_backfills(conn).await?;
validate_applied_backfills(&applied_backfills)?; validate_applied_backfills(&applied_backfills)?;
Ok(pending_backfills_from_applied(&applied_backfills)) Ok(pending_backfills_from_applied(&applied_backfills))
} }
async fn ensure_schema_backfills_table(conn: &mut PgConnection) -> Result<(), MigrateError> { async fn schema_backfills_table_exists(conn: &mut PgConnection) -> Result<bool, MigrateError> {
let exists: bool = query_scalar(SCHEMA_BACKFILLS_TABLE_EXISTS_SQL) query_scalar(SCHEMA_BACKFILLS_TABLE_EXISTS_SQL)
.fetch_one(&mut *conn) .fetch_one(&mut *conn)
.await?; .await
if exists { .map_err(Into::into)
}
async fn ensure_schema_backfills_table(conn: &mut PgConnection) -> Result<(), MigrateError> {
if schema_backfills_table_exists(conn).await? {
return Ok(()); return Ok(());
} }
query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL) query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL)
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use sqlx::{ use sqlx::{
migrate::{Migrate, MigrateError, Migrator}, migrate::{Migrate, MigrateError, Migrator},
query, Connection, Row, SqliteConnection, query, query_scalar, Connection, Row, SqliteConnection,
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -11,6 +11,8 @@ use crate::driver::sqlite::SqlitePool;
static BACKFILL_MIGRATOR: Migrator = sqlx::migrate!("./backfills/sqlite"); static BACKFILL_MIGRATOR: Migrator = sqlx::migrate!("./backfills/sqlite");
const SCHEMA_BACKFILLS_TABLE_EXISTS_SQL: &str =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_backfills'";
const ENSURE_SCHEMA_BACKFILLS_TABLE_SQL: &str = r#" const ENSURE_SCHEMA_BACKFILLS_TABLE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS schema_backfills ( CREATE TABLE IF NOT EXISTS schema_backfills (
version INTEGER NOT NULL PRIMARY KEY, version INTEGER NOT NULL PRIMARY KEY,
@@ -162,12 +164,21 @@ async fn run_backfills_locked(conn: &mut SqliteConnection) -> Result<(), Migrate
async fn pending_backfills_locked( async fn pending_backfills_locked(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
) -> Result<Vec<PendingBackfillInfo>, MigrateError> { ) -> Result<Vec<PendingBackfillInfo>, MigrateError> {
ensure_schema_backfills_table(conn).await?; if !schema_backfills_table_exists(conn).await? {
return Ok(pending_backfills_from_applied(&[]));
}
let applied_backfills = list_applied_backfills(conn).await?; let applied_backfills = list_applied_backfills(conn).await?;
validate_applied_backfills(&applied_backfills)?; validate_applied_backfills(&applied_backfills)?;
Ok(pending_backfills_from_applied(&applied_backfills)) Ok(pending_backfills_from_applied(&applied_backfills))
} }
async fn schema_backfills_table_exists(conn: &mut SqliteConnection) -> Result<bool, MigrateError> {
let total: i64 = query_scalar(SCHEMA_BACKFILLS_TABLE_EXISTS_SQL)
.fetch_one(&mut *conn)
.await?;
Ok(total > 0)
}
async fn ensure_schema_backfills_table(conn: &mut SqliteConnection) -> Result<(), MigrateError> { async fn ensure_schema_backfills_table(conn: &mut SqliteConnection) -> Result<(), MigrateError> {
query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL) query(ENSURE_SCHEMA_BACKFILLS_TABLE_SQL)
.execute(&mut *conn) .execute(&mut *conn)
@@ -310,6 +310,31 @@ INSERT INTO usage_settlement_snapshots (
} }
} }
#[tokio::test]
async fn pending_sqlite_backfills_does_not_create_tracking_table() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite backfill status pool should connect");
run_sqlite_migrations(&pool)
.await
.expect("sqlite schema should migrate");
let pending = pending_sqlite_backfills(&pool)
.await
.expect("sqlite pending backfills should load");
assert!(!pending.is_empty());
let tracking_tables: i64 = query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_backfills'",
)
.fetch_one(&pool)
.await
.expect("sqlite tracking table state should load");
assert_eq!(tracking_tables, 0);
}
#[tokio::test] #[tokio::test]
async fn sqlite_backfills_apply_portable_repairs_and_record_versions() { async fn sqlite_backfills_apply_portable_repairs_and_record_versions() {
let pool = sqlx::sqlite::SqlitePoolOptions::new() let pool = sqlx::sqlite::SqlitePoolOptions::new()
@@ -59,7 +59,14 @@ impl RoutingGroupReadRepository for InMemoryRoutingGroupRepository {
.values() .values()
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
groups.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); groups.sort_by(|left, right| {
right
.enabled
.cmp(&left.enabled)
.then(left.sort_order.cmp(&right.sort_order))
.then(left.name.cmp(&right.name))
.then(left.id.cmp(&right.id))
});
Ok(groups) Ok(groups)
} }
@@ -273,6 +280,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default: true, is_system_default: true,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
@@ -320,6 +328,49 @@ mod tests {
); );
} }
#[tokio::test]
async fn lists_enabled_groups_first_and_respects_sort_order() {
let repository = InMemoryRoutingGroupRepository::default();
for (id, enabled, sort_order) in [
("disabled-first", false, 0),
("enabled-second", true, 20),
("enabled-first", true, 10),
] {
repository
.create_routing_group(CreateRoutingGroupRecord {
id: id.to_string(),
name: id.to_string(),
description: None,
enabled,
is_system_default: false,
sort_order,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("group should store");
}
let ids = repository
.list_routing_groups()
.await
.expect("groups should list")
.into_iter()
.map(|group| group.id)
.collect::<Vec<_>>();
assert_eq!(
ids,
vec![
"enabled-first".to_string(),
"enabled-second".to_string(),
"disabled-first".to_string(),
]
);
}
#[tokio::test] #[tokio::test]
async fn keeps_system_and_subject_defaults_unique() { async fn keeps_system_and_subject_defaults_unique() {
let repository = InMemoryRoutingGroupRepository::default(); let repository = InMemoryRoutingGroupRepository::default();
@@ -414,6 +465,7 @@ mod tests {
description: None, description: None,
enabled: true, enabled: true,
is_system_default, is_system_default,
sort_order: 0,
config_json: json!({}), config_json: json!({}),
version: 1, version: 1,
created_at: 1, created_at: 1,
+3 -3
View File
@@ -13,9 +13,9 @@ pub use actions::{
}; };
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp}; pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
pub use model::{ pub use model::{
RoutingDefaultPolicy, RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig, RoutingDefaultPolicy, RoutingExecutionPolicy, RoutingGroupBinding, RoutingGroupBindingSubject,
RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride, RoutingGroupConfig, RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy,
RoutingRule, RoutingSchedulingPreset, DEFAULT_STICKY_KEY_ATTEMPTS, RoutingPoolPolicyOverride, RoutingRule, RoutingSchedulingPreset, DEFAULT_STICKY_KEY_ATTEMPTS,
}; };
pub use mutations::{ pub use mutations::{
apply_json_patch_operations, validate_header_patch, validate_json_patch_operations, apply_json_patch_operations, validate_header_patch, validate_json_patch_operations,
+52 -2
View File
@@ -27,6 +27,47 @@ pub struct RoutingPoolPolicyOverride {
/// failing over: one retry on the same key. /// failing over: one retry on the same key.
pub const DEFAULT_STICKY_KEY_ATTEMPTS: u32 = 2; pub const DEFAULT_STICKY_KEY_ATTEMPTS: u32 = 2;
/// Request-independent execution behaviours selected by a routing strategy.
///
/// These flags deliberately live beside scheduling rather than in provider
/// transport configuration. A resolved policy is snapshotted for the request
/// and can therefore be consumed by execution without rereading mutable
/// system settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub struct RoutingExecutionPolicy {
#[serde(default, skip_serializing_if = "is_false")]
pub enable_cf_heartbeat: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub cyber_continue_failover: bool,
}
impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize, Default)]
struct LegacyCompatibleExecutionPolicy {
#[serde(default)]
enable_cf_heartbeat: bool,
#[serde(default)]
enable_openai_image_sync_heartbeat: bool,
#[serde(default)]
enable_standard_text_sync_heartbeat: bool,
#[serde(default)]
cyber_continue_failover: bool,
}
let value = LegacyCompatibleExecutionPolicy::deserialize(deserializer)?;
Ok(Self {
enable_cf_heartbeat: value.enable_cf_heartbeat
|| value.enable_openai_image_sync_heartbeat
|| value.enable_standard_text_sync_heartbeat,
cyber_continue_failover: value.cyber_continue_failover,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingDefaultPolicy { pub struct RoutingDefaultPolicy {
#[serde(default)] #[serde(default)]
@@ -40,6 +81,10 @@ pub struct RoutingDefaultPolicy {
/// `0` and `1` both mean no same-key retry. /// `0` and `1` both mean no same-key retry.
#[serde(default = "default_sticky_key_attempts")] #[serde(default = "default_sticky_key_attempts")]
pub sticky_key_attempts: u32, pub sticky_key_attempts: u32,
/// Strategy-scoped execution behaviour. Flattened for a stable JSON
/// shape and backwards-compatible migration from system settings.
#[serde(flatten)]
pub execution_policy: RoutingExecutionPolicy,
} }
impl Default for RoutingDefaultPolicy { impl Default for RoutingDefaultPolicy {
@@ -49,6 +94,7 @@ impl Default for RoutingDefaultPolicy {
scheduling_mode: RoutingSchedulingMode::default(), scheduling_mode: RoutingSchedulingMode::default(),
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: RoutingExecutionPolicy::default(),
} }
} }
} }
@@ -57,6 +103,10 @@ fn default_sticky_key_attempts() -> u32 {
DEFAULT_STICKY_KEY_ATTEMPTS DEFAULT_STICKY_KEY_ATTEMPTS
} }
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingModelPolicy { pub struct RoutingModelPolicy {
pub model: String, pub model: String,
@@ -100,8 +150,8 @@ pub struct RoutingRule {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingGroupConfig { pub struct RoutingGroupConfig {
#[serde(default)] /// The default policy is global for the selected strategy group. Model
pub allowed_models: Vec<String>, /// differences are expressed through `model_policies` and `rules`.
#[serde(default)] #[serde(default)]
pub default_policy: RoutingDefaultPolicy, pub default_policy: RoutingDefaultPolicy,
#[serde(default)] #[serde(default)]
+41 -45
View File
@@ -8,7 +8,9 @@ use crate::actions::{
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode, RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
}; };
use crate::conditions::RoutingConditionContext; use crate::conditions::RoutingConditionContext;
use crate::model::{RoutingGroupConfig, RoutingModelPolicy, RoutingPoolPolicyOverride}; use crate::model::{
RoutingExecutionPolicy, RoutingGroupConfig, RoutingModelPolicy, RoutingPoolPolicyOverride,
};
use crate::mutations::{validate_header_patch, validate_json_patch_operations, MutationPlan}; use crate::mutations::{validate_header_patch, validate_json_patch_operations, MutationPlan};
use crate::ranking::RankingOverlay; use crate::ranking::RankingOverlay;
use crate::validation::validate_routing_group_config; use crate::validation::validate_routing_group_config;
@@ -17,7 +19,7 @@ use crate::validation::validate_routing_group_config;
pub enum RoutingPolicyError { pub enum RoutingPolicyError {
#[error("routing group config is invalid: {0}")] #[error("routing group config is invalid: {0}")]
InvalidConfig(String), InvalidConfig(String),
#[error("model is not allowed by routing group: {0}")] #[error("model is not allowed by routing rule: {0}")]
ModelNotAllowed(String), ModelNotAllowed(String),
#[error("mutation action is invalid: {0}")] #[error("mutation action is invalid: {0}")]
InvalidMutation(String), InvalidMutation(String),
@@ -60,6 +62,8 @@ pub struct ResolvedRoutingPolicy {
/// See `RoutingDefaultPolicy::sticky_key_attempts`. /// See `RoutingDefaultPolicy::sticky_key_attempts`.
#[serde(default = "default_sticky_key_attempts")] #[serde(default = "default_sticky_key_attempts")]
pub sticky_key_attempts: u32, pub sticky_key_attempts: u32,
#[serde(flatten)]
pub execution_policy: RoutingExecutionPolicy,
pub ranking_overlay: RankingOverlay, pub ranking_overlay: RankingOverlay,
pub mutation_plan: MutationPlan, pub mutation_plan: MutationPlan,
#[serde(default)] #[serde(default)]
@@ -75,14 +79,6 @@ pub fn resolve_routing_policy(
validate_routing_group_config(config) validate_routing_group_config(config)
.map_err(|error| RoutingPolicyError::InvalidConfig(error.to_string()))?; .map_err(|error| RoutingPolicyError::InvalidConfig(error.to_string()))?;
if !model_allowed(&config.allowed_models, input.requested_model)
&& !model_allowed(&config.allowed_models, input.resolved_model)
{
return Err(RoutingPolicyError::ModelNotAllowed(
input.requested_model.to_string(),
));
}
let mut policy = ResolvedRoutingPolicy { let mut policy = ResolvedRoutingPolicy {
group_id: input.group_id.map(str::to_string), group_id: input.group_id.map(str::to_string),
group_version: input.group_version, group_version: input.group_version,
@@ -93,6 +89,7 @@ pub fn resolve_routing_policy(
scheduling_mode: config.default_policy.scheduling_mode, scheduling_mode: config.default_policy.scheduling_mode,
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion, keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
sticky_key_attempts: config.default_policy.sticky_key_attempts, sticky_key_attempts: config.default_policy.sticky_key_attempts,
execution_policy: config.default_policy.execution_policy,
ranking_overlay: RankingOverlay::default(), ranking_overlay: RankingOverlay::default(),
mutation_plan: MutationPlan::default(), mutation_plan: MutationPlan::default(),
pool_policy_overrides: BTreeMap::new(), pool_policy_overrides: BTreeMap::new(),
@@ -322,7 +319,6 @@ mod tests {
#[test] #[test]
fn resolves_model_policy_and_matching_rule() { fn resolves_model_policy_and_matching_rule() {
let config = RoutingGroupConfig { let config = RoutingGroupConfig {
allowed_models: vec!["gpt-*".to_string()],
default_policy: RoutingDefaultPolicy::default(), default_policy: RoutingDefaultPolicy::default(),
model_policies: vec![RoutingModelPolicy { model_policies: vec![RoutingModelPolicy {
model: "gpt-5".to_string(), model: "gpt-5".to_string(),
@@ -389,14 +385,14 @@ mod tests {
} }
#[test] #[test]
fn empty_allowlist_keeps_default_policy_for_models_without_an_override() { fn default_policy_applies_to_models_without_an_override() {
let config = RoutingGroupConfig { let config = RoutingGroupConfig {
allowed_models: vec![],
default_policy: RoutingDefaultPolicy { default_policy: RoutingDefaultPolicy {
priority_mode: RoutingSetPriorityMode::GlobalKey, priority_mode: RoutingSetPriorityMode::GlobalKey,
scheduling_mode: RoutingSchedulingMode::LoadBalance, scheduling_mode: RoutingSchedulingMode::LoadBalance,
keep_priority_on_conversion: true, keep_priority_on_conversion: true,
sticky_key_attempts: 3, sticky_key_attempts: 3,
execution_policy: Default::default(),
}, },
model_policies: vec![RoutingModelPolicy { model_policies: vec![RoutingModelPolicy {
model: "special-model".to_string(), model: "special-model".to_string(),
@@ -471,6 +467,38 @@ mod tests {
.is_empty()); .is_empty());
} }
#[test]
fn legacy_group_model_allowlist_is_ignored() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"allowed_models": ["gpt-5"],
"default_policy": {
"priority_mode": "provider",
"scheduling_mode": "cache_affinity"
},
"model_policies": [],
"rules": []
}))
.expect("legacy routing config should remain readable");
resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "system_default",
requested_model: "claude-sonnet",
resolved_model: "claude-sonnet",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("the legacy allowlist must not reject another model");
}
#[test] #[test]
fn sticky_key_attempts_defaults_to_two_and_can_be_overridden_by_rule() { fn sticky_key_attempts_defaults_to_two_and_can_be_overridden_by_rule() {
let default_config = RoutingGroupConfig::default(); let default_config = RoutingGroupConfig::default();
@@ -541,41 +569,9 @@ mod tests {
assert_eq!(policy.sticky_key_attempts, 1); assert_eq!(policy.sticky_key_attempts, 1);
} }
#[test]
fn rejects_disallowed_model() {
let config = RoutingGroupConfig {
allowed_models: vec!["gpt-5".to_string()],
..RoutingGroupConfig::default()
};
let err = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: None,
group_version: None,
selection_source: "test",
requested_model: "claude",
resolved_model: "claude",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.unwrap_err();
assert_eq!(
err,
RoutingPolicyError::ModelNotAllowed("claude".to_string())
);
}
#[test] #[test]
fn restrict_model_action_rejects_matching_request() { fn restrict_model_action_rejects_matching_request() {
let config = RoutingGroupConfig { let config = RoutingGroupConfig {
allowed_models: vec!["*".to_string()],
rules: vec![RoutingRule { rules: vec![RoutingRule {
id: "restrict".to_string(), id: "restrict".to_string(),
priority: 1, priority: 1,
+2 -2
View File
@@ -228,6 +228,6 @@ docker image prune -f >/dev/null 2>&1 || true
echo ">>> Done!" echo ">>> Done!"
echo ">>> Note: empty databases auto-bootstrap on first start." echo ">>> Note: empty databases auto-bootstrap on first start."
echo ">>> Note: docker compose now defaults to auto-running pending migrations/backfills on app startup." echo ">>> Note: database schema and data preparation run automatically before app startup."
echo ">>> Note: set AETHER_GATEWAY_AUTO_PREPARE_DATABASE=false if you want to keep manual rollout." echo ">>> Note: set AETHER_GATEWAY_DATABASE_MODE=verify-only to require a separate database prepare step."
"${DC[@]}" ps "${DC[@]}" ps
+3
View File
@@ -13,6 +13,7 @@ export interface RoutingGroupRecord {
description?: string | null description?: string | null
enabled: boolean enabled: boolean
is_system_default: boolean is_system_default: boolean
sort_order: number
config_json: RoutingGroupConfig config_json: RoutingGroupConfig
version: number version: number
created_at: number created_at: number
@@ -61,6 +62,7 @@ export interface RoutingGroupCreateRequest {
description?: string | null description?: string | null
enabled?: boolean enabled?: boolean
is_system_default?: boolean is_system_default?: boolean
sort_order?: number
config_json?: RoutingGroupConfig config_json?: RoutingGroupConfig
} }
@@ -69,6 +71,7 @@ export interface RoutingGroupUpdateRequest {
description?: string | null description?: string | null
enabled?: boolean enabled?: boolean
is_system_default?: boolean is_system_default?: boolean
sort_order?: number
config_json?: RoutingGroupConfig config_json?: RoutingGroupConfig
version?: number version?: number
published_at?: number | null published_at?: number | null
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CircleHelp } from 'lucide-vue-next'
const props = defineProps<{
label: string
text: string
}>()
const open = ref(false)
</script>
<template>
<span class="group relative inline-flex">
<button
type="button"
class="inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="`${props.label}说明`"
:aria-expanded="open"
:title="props.text"
@click.stop="open = !open"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
<span
role="tooltip"
class="pointer-events-none invisible absolute left-1/2 top-full z-[230] mt-2 w-max max-w-xs -translate-x-1/2 rounded-md border bg-popover px-3 py-2 text-xs leading-5 text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
:class="open ? 'visible opacity-100' : ''"
>
{{ props.text }}
</span>
</span>
</template>
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,7 @@
:provider-proxy-node-name="getProviderProxyNodeName()" :provider-proxy-node-name="getProviderProxyNodeName()"
:saving-provider-proxy="savingProviderProxy" :saving-provider-proxy="savingProviderProxy"
@toggle-format-conversion="toggleFormatConversion" @toggle-format-conversion="toggleFormatConversion"
@toggle-keep-priority-on-conversion="toggleKeepPriorityOnConversion"
@open-failover-rules="failoverRulesDialogOpen = true" @open-failover-rules="failoverRulesDialogOpen = true"
@set-provider-proxy="setProviderProxy" @set-provider-proxy="setProviderProxy"
@clear-provider-proxy="clearProviderProxy" @clear-provider-proxy="clearProviderProxy"
@@ -1412,6 +1413,24 @@ async function toggleFormatConversion() {
} }
} }
async function toggleKeepPriorityOnConversion() {
if (!provider.value) return
const formatConversionAvailable =
provider.value.enable_format_conversion || systemFormatConversionEnabled.value
if (!formatConversionAvailable) return
const newValue = !provider.value.keep_priority_on_conversion
try {
const updated = await updateProvider(provider.value.id, {
keep_priority_on_conversion: newValue,
})
applyProviderSnapshot(updated)
showSuccess(legacyT(newValue ? '已启用格式转换保持优先级' : '已禁用格式转换保持优先级'))
emit('refresh')
} catch {
showError(legacyT('切换格式转换保持优先级失败'))
}
}
function getProviderProxyNodeName(): string { function getProviderProxyNodeName(): string {
const nodeId = provider.value?.proxy?.node_id const nodeId = provider.value?.proxy?.node_id
if (!nodeId) return legacyT('未知节点') if (!nodeId) return legacyT('未知节点')
@@ -24,6 +24,17 @@
<Shuffle class="w-4 h-4" /> <Shuffle class="w-4 h-4" />
</Button> </Button>
</span> </span>
<span :title="keepPriorityTitle">
<Button
variant="ghost"
size="icon"
:class="provider.keep_priority_on_conversion ? 'text-primary' : ''"
:disabled="!formatConversionAvailable"
@click="$emit('toggleKeepPriorityOnConversion')"
>
<Layers class="w-4 h-4" />
</Button>
</span>
<span :title="legacyT(hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则')"> <span :title="legacyT(hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则')">
<Button <Button
variant="ghost" variant="ghost"
@@ -163,7 +174,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { Edit, GitBranch, Globe, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next' import { Edit, GitBranch, Globe, Layers, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
@@ -185,6 +196,7 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
(e: 'toggleFormatConversion'): void (e: 'toggleFormatConversion'): void
(e: 'toggleKeepPriorityOnConversion'): void
(e: 'openFailoverRules'): void (e: 'openFailoverRules'): void
(e: 'update:providerProxyPopoverOpen', value: boolean): void (e: 'update:providerProxyPopoverOpen', value: boolean): void
(e: 'setProviderProxy', value: string): void (e: 'setProviderProxy', value: string): void
@@ -203,4 +215,15 @@ const formatConversionTitle = computed(() => {
if (props.provider.enable_format_conversion) return legacyT('已启用格式转换(点击关闭)') if (props.provider.enable_format_conversion) return legacyT('已启用格式转换(点击关闭)')
return legacyT('启用格式转换') return legacyT('启用格式转换')
}) })
const formatConversionAvailable = computed(() => (
props.provider.enable_format_conversion || props.systemFormatConversionEnabled
))
const keepPriorityTitle = computed(() => {
if (!formatConversionAvailable.value) return legacyT('请先启用格式转换')
return props.provider.keep_priority_on_conversion
? legacyT('已启用格式转换保持优先级(点击关闭)')
: legacyT('启用格式转换保持优先级')
})
</script> </script>
@@ -280,19 +280,6 @@
{{ legacyT('功能开关') }} {{ legacyT('功能开关') }}
</h3> </h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('格式转换保持优先级') }}</span>
<p class="text-xs text-muted-foreground">
{{ legacyT('跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后') }}
</p>
</div>
<Switch
:model-value="form.keep_priority_on_conversion"
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50"> <div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5"> <div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span> <span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span>
@@ -475,7 +462,6 @@ const form = ref({
quota_last_reset_at: '', // 周期开始时间 quota_last_reset_at: '', // 周期开始时间
quota_expires_at: '', quota_expires_at: '',
provider_priority: 100, provider_priority: 100,
keep_priority_on_conversion: false, // 格式转换时是否保持优先级
// 状态配置 // 状态配置
is_active: true, is_active: true,
rate_limit: undefined as number | undefined, rate_limit: undefined as number | undefined,
@@ -510,7 +496,6 @@ function resetForm() {
quota_last_reset_at: '', quota_last_reset_at: '',
quota_expires_at: '', quota_expires_at: '',
provider_priority: defaultPriority.value, provider_priority: defaultPriority.value,
keep_priority_on_conversion: false,
is_active: true, is_active: true,
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined, concurrent_limit: undefined,
@@ -548,7 +533,6 @@ function loadProviderData() {
quota_last_reset_at: formatDateTimeLocalInput(props.provider.quota_last_reset_at), quota_last_reset_at: formatDateTimeLocalInput(props.provider.quota_last_reset_at),
quota_expires_at: formatDateTimeLocalInput(props.provider.quota_expires_at), quota_expires_at: formatDateTimeLocalInput(props.provider.quota_expires_at),
provider_priority: props.provider.provider_priority || 999, provider_priority: props.provider.provider_priority || 999,
keep_priority_on_conversion: props.provider.keep_priority_on_conversion ?? false,
is_active: props.provider.is_active, is_active: props.provider.is_active,
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined, concurrent_limit: undefined,
@@ -625,7 +609,6 @@ const handleSubmit = async () => {
quota_reset_day: form.value.quota_reset_day, quota_reset_day: form.value.quota_reset_day,
quota_last_reset_at: quotaLastResetAt, quota_last_reset_at: quotaLastResetAt,
quota_expires_at: quotaExpiresAt, quota_expires_at: quotaExpiresAt,
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
responses_websocket_enabled: form.value.responses_websocket_enabled, responses_websocket_enabled: form.value.responses_websocket_enabled,
is_active: form.value.is_active, is_active: form.value.is_active,
// 请求配置 // 请求配置
@@ -98,19 +98,6 @@
<div class="hidden sm:block h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
:title="legacyT('点击调整调度策略')"
@click="$emit('openPriorityDialog')"
>
<span class="text-muted-foreground/80 hidden sm:inline">{{ legacyT('调度:') }}</span>
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button <Button
variant="ghost" variant="ghost"
@@ -141,7 +128,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Search, Plus, ChevronDown, FilterX, Users } from 'lucide-vue-next' import { Search, Plus, FilterX, Users } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
import Select from '@/components/ui/select.vue' import Select from '@/components/ui/select.vue'
@@ -162,7 +149,6 @@ defineProps<{
apiFormatFilters: FilterOption[] apiFormatFilters: FilterOption[]
modelFilters: FilterOption[] modelFilters: FilterOption[]
hasActiveFilters: boolean hasActiveFilters: boolean
priorityModeLabel: string
loading: boolean loading: boolean
}>() }>()
@@ -172,7 +158,6 @@ defineEmits<{
'update:filterApiFormat': [value: string] 'update:filterApiFormat': [value: string]
'update:filterModel': [value: string] 'update:filterModel': [value: string]
'resetFilters': [] 'resetFilters': []
'openPriorityDialog': []
'batchProcess': [] 'batchProcess': []
'addProvider': [] 'addProvider': []
'refresh': [] 'refresh': []
@@ -9,7 +9,6 @@ export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
export { default as KeyFormDialog } from './KeyFormDialog.vue' export { default as KeyFormDialog } from './KeyFormDialog.vue'
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue' export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue' export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue'
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue' export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue' export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue' export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
@@ -2,27 +2,17 @@ import { describe, expect, it } from 'vitest'
import { import {
DEFAULT_ROUTING_POLICY_MODEL, DEFAULT_ROUTING_POLICY_MODEL,
allowedModelsMirrorPerModelPolicies,
clearAllowedModels,
copyPerModelRoutingConfig,
createEmptyModelPolicy, createEmptyModelPolicy,
createEmptyRoutingGroupConfig, createEmptyRoutingGroupConfig,
formatAllowedModelsInput,
getDefaultModelPolicy, getDefaultModelPolicy,
getModelScheduling, getModelScheduling,
modelSchedulingRuleId, modelSchedulingRuleId,
normalizeRoutingGroupConfig, normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts, normalizeStickyKeyAttempts,
parseAllowedModelsInput,
removePerModelRoutingConfig,
resolveModelKeyPriorityOverride, resolveModelKeyPriorityOverride,
routingModelScopeLabel,
savePerModelRoutingConfig,
setDefaultPoolPriorityOverrides, setDefaultPoolPriorityOverrides,
setDefaultProviderPriorityOverrides, setDefaultProviderPriorityOverrides,
setModelKeyPriorityOverridesForFormat, setModelKeyPriorityOverridesForFormat,
setRoutingSortingScope,
updateAllowedModelsFromInput,
upsertModelSchedulingRule, upsertModelSchedulingRule,
upsertModelPolicy, upsertModelPolicy,
} from '../utils/routingPolicy' } from '../utils/routingPolicy'
@@ -30,13 +20,18 @@ import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace }
describe('routingPolicy', () => { describe('routingPolicy', () => {
it('normalizes partial configs with stable defaults', () => { it('normalizes partial configs with stable defaults', () => {
const config = normalizeRoutingGroupConfig({ const config = normalizeRoutingGroupConfig({})
allowed_models: ['gpt-5'],
})
expect(config.default_policy.priority_mode).toBe('provider') expect(config.default_policy.priority_mode).toBe('provider')
expect(config.default_policy.scheduling_mode).toBe('cache_affinity') expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
expect(config.allowed_models).toEqual(['gpt-5']) })
it('drops the legacy group model allowlist while normalizing config', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['legacy-model'],
} as unknown as Parameters<typeof normalizeRoutingGroupConfig>[0])
expect(config).not.toHaveProperty('allowed_models')
}) })
it('upserts model policies by model name', () => { it('upserts model policies by model name', () => {
@@ -155,115 +150,6 @@ describe('routingPolicy', () => {
scheduling_mode: 'fixed_order', scheduling_mode: 'fixed_order',
}) })
}) })
it('updates the model allowlist only through explicit scope controls', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['legacy-model'],
})
expect(parseAllowedModelsInput(' gpt-5\nclaude-*\nlegacy-model\ngpt-5 ')).toEqual([
'gpt-5',
'claude-*',
'legacy-model',
])
const restricted = updateAllowedModelsFromInput(
config,
'gpt-5\nclaude-*\nlegacy-model\ngpt-5',
)
expect(restricted.allowed_models).toEqual(['gpt-5', 'claude-*', 'legacy-model'])
expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5\nclaude-*\nlegacy-model')
expect(routingModelScopeLabel(restricted)).toBe('3 个模型')
const unrestricted = clearAllowedModels(restricted)
expect(unrestricted.allowed_models).toEqual([])
expect(routingModelScopeLabel(unrestricted)).toBe('全部模型')
})
it('round-trips selectors containing commas and labels wildcard scope as unrestricted', () => {
const selectors = ['vendor,model', 'gpt-*']
expect(parseAllowedModelsInput(formatAllowedModelsInput(selectors))).toEqual(selectors)
const wildcard = normalizeRoutingGroupConfig({ allowed_models: ['gpt-*', '*'] })
expect(routingModelScopeLabel(wildcard)).toBe('全部模型')
})
it('preserves historical empty selectors until unrestricted scope is explicit', () => {
const legacy = normalizeRoutingGroupConfig({ allowed_models: ['', ' '] })
expect(updateAllowedModelsFromInput(legacy, ' \n')).toMatchObject({
allowed_models: ['', ' '],
})
expect(clearAllowedModels(legacy).allowed_models).toEqual([])
})
it('preserves an explicit model allowlist across per-model editing actions', () => {
const allowlist = ['gpt-*', 'legacy-model']
let config = normalizeRoutingGroupConfig({
allowed_models: allowlist,
model_policies: [{
...createEmptyModelPolicy('special-model'),
allowed_providers: ['provider-special'],
}],
})
config = upsertModelSchedulingRule(config, 'special-model', {
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const perModel = setRoutingSortingScope(config, 'per_model')
expect(perModel.allowed_models).toEqual(allowlist)
expect(getModelScheduling(perModel, 'special-model')).toMatchObject({
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const saved = savePerModelRoutingConfig(perModel, 'new-special-model')
expect(saved.allowed_models).toEqual(allowlist)
expect(saved.model_policies.map(policy => policy.model)).toContain('new-special-model')
const copied = copyPerModelRoutingConfig(
saved,
saved,
'special-model',
'copied-special-model',
)
expect(copied.allowed_models).toEqual(allowlist)
expect(copied.model_policies.find(policy => policy.model === 'copied-special-model'))
.toMatchObject({ allowed_providers: ['provider-special'] })
expect(getModelScheduling(copied, 'copied-special-model')).toMatchObject({
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const removed = removePerModelRoutingConfig(copied, 'special-model')
expect(removed.allowed_models).toEqual(allowlist)
expect(removed.model_policies.map(policy => policy.model)).not.toContain('special-model')
expect(removed.rules.map(rule => rule.id)).not.toContain(modelSchedulingRuleId('special-model'))
const unified = setRoutingSortingScope(removed, 'unified')
expect(unified.allowed_models).toEqual(allowlist)
expect(unified.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL))
.toEqual([])
expect(unified.rules.some(rule => rule.id.startsWith('ui_model_scheduling:'))).toBe(false)
})
it('recognizes legacy allowlist mirrors without mutating historical values', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: [' model-b ', 'model-a', 'model-a'],
model_policies: [
createEmptyModelPolicy('model-a'),
createEmptyModelPolicy('model-b'),
],
})
expect(allowedModelsMirrorPerModelPolicies(config)).toBe(true)
expect(config.allowed_models).toEqual([' model-b ', 'model-a', 'model-a'])
expect(allowedModelsMirrorPerModelPolicies({
...config,
allowed_models: ['model-*'],
})).toBe(false)
})
}) })
describe('routingTrace', () => { describe('routingTrace', () => {
@@ -1,16 +1,5 @@
<template> <template>
<section class="space-y-4"> <section class="space-y-4">
<div class="grid gap-3">
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">允许模型</span>
<input
v-model="allowedModelsText"
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
placeholder="gpt-5, claude-sonnet-*"
>
</label>
</div>
<RoutingModelPolicyEditor <RoutingModelPolicyEditor
:model-policies="config.model_policies" :model-policies="config.model_policies"
@update:model-policies="updateModelPolicies" @update:model-policies="updateModelPolicies"
@@ -34,16 +23,6 @@ const emit = defineEmits<{
const config = computed(() => normalizeRoutingGroupConfig(props.config)) const config = computed(() => normalizeRoutingGroupConfig(props.config))
const allowedModelsText = computed({
get: () => config.value.allowed_models.join(', '),
set: value => {
emit('update:config', {
...config.value,
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
})
},
})
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) { function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
emit('update:config', { emit('update:config', {
...config.value, ...config.value,
@@ -10,6 +10,8 @@ export interface RoutingDefaultPolicy {
priority_mode: RoutingPriorityMode priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode scheduling_mode: RoutingSchedulingMode
keep_priority_on_conversion: boolean keep_priority_on_conversion: boolean
enable_cf_heartbeat: boolean
cyber_continue_failover: boolean
/** 首个候选的总尝试次数;后续候选始终只尝试 1 次。0 或 1 表示不重试 */ /** 首个候选的总尝试次数;后续候选始终只尝试 1 次。0 或 1 表示不重试 */
sticky_key_attempts: number sticky_key_attempts: number
} }
@@ -60,7 +62,6 @@ export interface RoutingSetSchedulingAction {
} }
export interface RoutingGroupConfig { export interface RoutingGroupConfig {
allowed_models: string[]
default_policy: RoutingDefaultPolicy default_policy: RoutingDefaultPolicy
model_policies: RoutingModelPolicy[] model_policies: RoutingModelPolicy[]
rules: RoutingRule[] rules: RoutingRule[]
@@ -71,11 +72,12 @@ export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig { export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return { return {
allowed_models: [],
default_policy: { default_policy: {
priority_mode: 'provider', priority_mode: 'provider',
scheduling_mode: 'cache_affinity', scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false, keep_priority_on_conversion: false,
enable_cf_heartbeat: false,
cyber_continue_failover: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS, sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
}, },
model_policies: [], model_policies: [],
@@ -104,14 +106,25 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig { export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
const base = createEmptyRoutingGroupConfig() const base = createEmptyRoutingGroupConfig()
const rawDefaultPolicy = (value?.default_policy ?? {}) as Partial<RoutingDefaultPolicy> & {
enable_openai_image_sync_heartbeat?: boolean
enable_standard_text_sync_heartbeat?: boolean
}
const {
enable_openai_image_sync_heartbeat: legacyImageHeartbeat,
enable_standard_text_sync_heartbeat: legacyTextHeartbeat,
...defaultPolicyWithoutLegacyHeartbeat
} = rawDefaultPolicy
return { return {
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
default_policy: { default_policy: {
...base.default_policy, ...base.default_policy,
...(value?.default_policy ?? {}), ...defaultPolicyWithoutLegacyHeartbeat,
enable_cf_heartbeat: Boolean(
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
),
sticky_key_attempts: normalizeStickyKeyAttempts( sticky_key_attempts: normalizeStickyKeyAttempts(
value?.default_policy?.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS, rawDefaultPolicy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
), ),
}, },
model_policies: Array.isArray(value?.model_policies) model_policies: Array.isArray(value?.model_policies)
@@ -133,74 +146,6 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
} }
} }
export function parseAllowedModelsInput(value: string): string[] {
const seen = new Set<string>()
return value
.split(/\r\n?|\n/u)
.map(item => item.trim())
.filter(Boolean)
.filter((model) => {
if (seen.has(model)) return false
seen.add(model)
return true
})
}
export function formatAllowedModelsInput(models: string[]): string {
return models.join('\n')
}
export function updateAllowedModelsFromInput(
config: RoutingGroupConfig,
value: string,
): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
// Preserve the historical "empty selector" form until the user explicitly
// chooses the unrestricted scope. It is distinct from an empty allowlist in
// the routing core, where it matches no normal model.
const hasHistoricalEmptySelector = next.allowed_models.length > 0
&& next.allowed_models.every(model => model.trim() === '')
if (value.trim() === '' && hasHistoricalEmptySelector) {
return next
}
next.allowed_models = parseAllowedModelsInput(value)
return next
}
export function clearAllowedModels(config: RoutingGroupConfig): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.allowed_models = []
return next
}
export function routingModelScopeLabel(config: RoutingGroupConfig): string {
const models = normalizeRoutingGroupConfig(config).allowed_models
if (models.length === 0 || models.some(model => model.trim() === '*')) {
return '全部模型'
}
return `${models.length} 个模型`
}
export function allowedModelsMirrorPerModelPolicies(config: RoutingGroupConfig): boolean {
const normalized = normalizeRoutingGroupConfig(config)
const allowedModels = normalized.allowed_models
.map(model => model.trim())
.filter(Boolean)
const perModelNames = normalized.model_policies
.map(policy => policy.model)
.map(model => model.trim())
.filter(Boolean)
.filter(model => model !== DEFAULT_ROUTING_POLICY_MODEL)
if (allowedModels.length === 0 || perModelNames.length === 0) return false
if (allowedModels.some(model => model.includes('*'))) return false
const allowedSet = new Set(allowedModels)
const perModelSet = new Set(perModelNames)
return allowedSet.size === perModelSet.size
&& [...allowedSet].every(model => perModelSet.has(model))
}
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig { export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
const model = policy.model.trim() const model = policy.model.trim()
if (!model) { if (!model) {
@@ -427,6 +372,8 @@ export function getModelScheduling(
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode, priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode, scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion, keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
enable_cf_heartbeat: normalized.default_policy.enable_cf_heartbeat,
cyber_continue_failover: normalized.default_policy.cyber_continue_failover,
sticky_key_attempts: action?.sticky_key_attempts ?? normalized.default_policy.sticky_key_attempts, sticky_key_attempts: action?.sticky_key_attempts ?? normalized.default_policy.sticky_key_attempts,
} }
} }
-1
View File
@@ -3264,7 +3264,6 @@ const legacyFallbackTokens: Array<[string, string]> = [
['策略分组', 'policy groups'], ['策略分组', 'policy groups'],
['策略', 'policy'], ['策略', 'policy'],
['维度', 'dimension'], ['维度', 'dimension'],
['模型范围', 'model scope'],
['默认策略', 'default policy'], ['默认策略', 'default policy'],
['更新时间', 'updated at'], ['更新时间', 'updated at'],
['回溯时间', 'lookback time'], ['回溯时间', 'lookback time'],
@@ -24,14 +24,12 @@
:api-format-filters="apiFormatFilters" :api-format-filters="apiFormatFilters"
:model-filters="modelFilters" :model-filters="modelFilters"
:has-active-filters="hasActiveFilters" :has-active-filters="hasActiveFilters"
:priority-mode-label="priorityModeConfig.label"
:loading="loading" :loading="loading"
@update:search-query="searchQuery = $event" @update:search-query="searchQuery = $event"
@update:filter-status="filterStatus = $event" @update:filter-status="filterStatus = $event"
@update:filter-api-format="filterApiFormat = $event" @update:filter-api-format="filterApiFormat = $event"
@update:filter-model="filterModel = $event" @update:filter-model="filterModel = $event"
@reset-filters="resetFilters" @reset-filters="resetFilters"
@open-priority-dialog="openPriorityDialog"
@batch-process="openProviderBatchDialog" @batch-process="openProviderBatchDialog"
@add-provider="openAddProviderDialog" @add-provider="openAddProviderDialog"
@refresh="loadProviders" @refresh="loadProviders"
@@ -215,11 +213,6 @@
@changed="handleProviderBatchChanged" @changed="handleProviderBatchChanged"
/> />
<PriorityManagementDialog
v-model="priorityDialogOpen"
@saved="handlePrioritySaved"
/>
<ProviderDetailDrawer <ProviderDetailDrawer
v-if="providerDrawerMounted" v-if="providerDrawerMounted"
:open="providerDrawerOpen" :open="providerDrawerOpen"
@@ -250,7 +243,7 @@ import TableHead from '@/components/ui/table-head.vue'
import SortableTableHead from '@/components/ui/sortable-table-head.vue' import SortableTableHead from '@/components/ui/sortable-table-head.vue'
import TableFilterMenu from '@/components/ui/table-filter-menu.vue' import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
import Pagination from '@/components/ui/pagination.vue' import Pagination from '@/components/ui/pagination.vue'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components' import { ProviderFormDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue' import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue'
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue' import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue' import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
@@ -271,9 +264,6 @@ import {
getGlobalModels, getGlobalModels,
type ProviderWithEndpointsSummary, type ProviderWithEndpointsSummary,
} from '@/api/endpoints' } from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { listRoutingGroups } from '@/api/routing-profiles'
import { normalizeRoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
@@ -309,8 +299,6 @@ let providersRequestId = 0
const providerDialogOpen = ref(false) const providerDialogOpen = ref(false)
const providerBatchDialogOpen = ref(false) const providerBatchDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null) const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false) const providerDrawerOpen = ref(false)
const providerDrawerMounted = ref(false) const providerDrawerMounted = ref(false)
const selectedProviderId = ref<string | null>(null) const selectedProviderId = ref<string | null>(null)
@@ -325,7 +313,6 @@ const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000 const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3 const DELETE_POLL_MAX_FAILURES = 3
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000 const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
const PROVIDER_PRIORITY_MODE_CACHE_TTL_MS = 30 * 1000
const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000 const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000
async function pollProviderDeleteTask(providerId: string, taskId: string) { async function pollProviderDeleteTask(providerId: string, taskId: string) {
@@ -519,13 +506,6 @@ async function saveDescription(_event: Event, provider: ProviderWithEndpointsSum
} }
} }
// 优先级模式配置
const priorityModeConfig = computed(() => {
return {
label: legacyT(priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'),
}
})
// 当前已有提供商的最大优先级 // 当前已有提供商的最大优先级
const maxProviderPriority = computed(() => { const maxProviderPriority = computed(() => {
if (providers.value.length === 0) return undefined if (providers.value.length === 0) return undefined
@@ -535,30 +515,6 @@ const maxProviderPriority = computed(() => {
return priorities.length > 0 ? Math.max(...priorities) : undefined return priorities.length > 0 ? Math.max(...priorities) : undefined
}) })
// 加载优先级模式:优先使用启用中的系统默认调度策略,旧的系统配置键仅作兜底
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
try {
const groups = await listRoutingGroups()
const systemDefault = groups.items.find(group => group.is_system_default && group.enabled)
if (systemDefault) {
priorityMode.value = normalizeRoutingGroupConfig(systemDefault.config_json).default_policy.priority_mode
return
}
} catch {
// 路由策略不可用时继续尝试旧配置
}
try {
const response = await adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (response.value) {
priorityMode.value = response.value as 'provider' | 'global_key'
}
} catch {
priorityMode.value = 'provider'
}
}
// 加载全局模型列表(用于模型筛选下拉) // 加载全局模型列表(用于模型筛选下拉)
async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) { async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) {
try { try {
@@ -636,11 +592,6 @@ function openAddProviderDialog() {
providerDialogOpen.value = true providerDialogOpen.value = true
} }
// 打开优先级管理对话框
function openPriorityDialog() {
priorityDialogOpen.value = true
}
function openProviderBatchDialog() { function openProviderBatchDialog() {
providerBatchDialogOpen.value = true providerBatchDialogOpen.value = true
} }
@@ -709,12 +660,6 @@ async function handleDrawerRefresh() {
await refreshProviderSnapshot(selectedProviderId.value) await refreshProviderSnapshot(selectedProviderId.value)
} }
// 优先级保存成功回调
async function handlePrioritySaved() {
await loadProviders()
await loadPriorityMode()
}
// 处理提供商添加 // 处理提供商添加
function handleProviderAdded() { function handleProviderAdded() {
void loadProviders() void loadProviders()
@@ -791,7 +736,6 @@ function handleGlobalClick(event: MouseEvent) {
onMounted(() => { onMounted(() => {
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS }) void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
void loadPriorityMode({ cacheTtlMs: PROVIDER_PRIORITY_MODE_CACHE_TTL_MS })
void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS }) void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS })
void loadArchitectureSchemas() void loadArchitectureSchemas()
document.addEventListener('click', handleGlobalClick, true) document.addEventListener('click', handleGlobalClick, true)
File diff suppressed because it is too large Load Diff
@@ -81,9 +81,6 @@
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version" :registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys" :auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion" :enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat"
:cyber-continue-failover="systemConfig.cyber_continue_failover"
:loading="systemConfigLoading || basicConfigLoading" :loading="systemConfigLoading || basicConfigLoading"
:has-changes="hasBasicConfigChanges" :has-changes="hasBasicConfigChanges"
@save="saveBasicConfig" @save="saveBasicConfig"
@@ -107,9 +104,6 @@
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event" @update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event" @update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event" @update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
@update:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat = $event"
@update:cyber-continue-failover="systemConfig.cyber_continue_failover = $event"
/> />
<!-- 请求记录配置 --> <!-- 请求记录配置 -->
@@ -1,519 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import type {
RoutingGroupCreateRequest,
RoutingGroupRecord,
RoutingGroupUpdateRequest,
} from '@/api/routing-profiles'
const apiMocks = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
createRoutingGroup: vi.fn(),
updateRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
getGlobalModels: vi.fn(),
}))
const routeMocks = vi.hoisted(() => ({
route: null as null | { name: string; params: Record<string, string> },
push: vi.fn(),
replace: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('vue-router', async () => {
const { reactive } = await import('vue')
routeMocks.route = reactive({
name: 'RoutingProfileDetail',
params: { groupId: 'group-1' },
})
return {
useRoute: () => routeMocks.route,
useRouter: () => ({ push: routeMocks.push, replace: routeMocks.replace }),
}
})
vi.mock('@/api/routing-profiles', () => ({
listRoutingGroups: apiMocks.listRoutingGroups,
createRoutingGroup: apiMocks.createRoutingGroup,
updateRoutingGroup: apiMocks.updateRoutingGroup,
deleteRoutingGroup: apiMocks.deleteRoutingGroup,
}))
vi.mock('@/api/global-models', () => ({ getGlobalModels: apiMocks.getGlobalModels }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/layout', async () => {
const { defineComponent, h } = await import('vue')
return {
PageContainer: defineComponent({
setup(_, { slots }) {
return () => h('main', slots.default?.())
},
}),
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = (tag = 'div') => defineComponent({
inheritAttrs: false,
props: { class: String },
setup(props, { attrs, slots }) {
return () => h(tag, { ...attrs, class: props.class }, [
slots.header?.(),
slots.default?.(),
])
},
})
const Input = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: [String, Number], default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
})
const Textarea = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: String, default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('textarea', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLTextAreaElement).value,
),
})
},
})
const Button = defineComponent({
inheritAttrs: false,
props: {
class: String,
disabled: Boolean,
type: { type: String, default: 'button' },
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
class: props.class,
disabled: props.disabled,
type: props.type,
}, slots.default?.())
},
})
const Switch = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: Boolean, default: false },
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('button', {
...attrs,
type: 'button',
role: 'switch',
'aria-checked': props.modelValue,
disabled: props.disabled,
onClick: () => emit('update:modelValue', !props.modelValue),
})
},
})
return {
Badge: wrapper(),
Button,
Card: wrapper('section'),
Input,
Switch,
Table: wrapper('table'),
TableBody: wrapper('tbody'),
TableCard: wrapper(),
TableCell: wrapper('td'),
TableHead: wrapper('th'),
TableHeader: wrapper('thead'),
TableRow: wrapper('tr'),
Textarea,
}
})
vi.mock('@/components/ui/dropdown-menu', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
DropdownMenu: wrapper,
DropdownMenuContent: wrapper,
DropdownMenuItem: wrapper,
DropdownMenuTrigger: wrapper,
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return { AlertDialog: defineComponent({ setup: () => () => h('div') }) }
})
vi.mock('@/features/routing/components', async () => {
const { defineComponent, h } = await import('vue')
return {
RoutingPriorityPolicyEditor: defineComponent({
setup: () => () => h('div', { 'data-testid': 'routing-policy-editor' }),
}),
}
})
let app: App | undefined
let root: HTMLElement | undefined
function routingGroup(
allowedModels: string[] = [],
overrides: Partial<RoutingGroupRecord> = {},
): RoutingGroupRecord {
return {
id: 'group-1',
name: 'Default routing',
description: null,
enabled: true,
is_system_default: true,
config_json: {
allowed_models: allowedModels,
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
sticky_key_attempts: 2,
},
model_policies: [],
rules: [],
},
version: 1,
created_at: 1,
updated_at: 1,
published_at: null,
...overrides,
}
}
async function flushPromises(iterations = 5): Promise<void> {
for (let index = 0; index < iterations; index += 1) {
await Promise.resolve()
}
await nextTick()
}
async function mountPage(
input: RoutingGroupRecord | RoutingGroupRecord[] = routingGroup(),
): Promise<void> {
const groups = Array.isArray(input) ? input : [input]
apiMocks.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
apiMocks.getGlobalModels.mockResolvedValue({ models: [] })
apiMocks.updateRoutingGroup.mockImplementation(
async (groupId: string, payload: RoutingGroupUpdateRequest) => {
const group = groups.find(item => item.id === groupId)
if (!group) throw new Error(`unknown routing group: ${groupId}`)
return {
...group,
...payload,
config_json: payload.config_json ?? group.config_json,
updated_at: 2,
}
},
)
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(RoutingProfiles),
}))
app.mount(root)
await flushPromises()
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
textarea.value = value
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
beforeEach(() => {
vi.clearAllMocks()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
})
afterEach(() => {
app?.unmount()
root?.remove()
app = undefined
root = undefined
})
describe('RoutingProfiles model allowlist', () => {
it('saves one selector per line without an extra apply step', async () => {
await mountPage()
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
setTextareaValue(textarea, 'gpt-5\nclaude-*\nvendor,model')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
expect(saveButton.disabled).toBe(false)
saveButton.click()
await flushPromises()
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledWith(
'group-1',
expect.objectContaining({
config_json: expect.objectContaining({
allowed_models: ['gpt-5', 'claude-*', 'vendor,model'],
}),
}),
)
})
it('locks the editor while a save is in flight', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
const editor = root?.querySelector('[aria-busy="true"]') as HTMLElement
const clearButton = root?.querySelector(
'[data-testid="clear-allowed-models"]',
) as HTMLButtonElement
expect(editor.hasAttribute('inert')).toBe(true)
expect(textarea.disabled).toBe(true)
expect(clearButton.disabled).toBe(true)
expect(saveButton.disabled).toBe(true)
setTextareaValue(textarea, 'model-c')
await nextTick()
expect(submittedPayload?.config_json?.allowed_models).toEqual(['model-a', 'model-b'])
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('[aria-busy="true"]')).toBeNull()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledTimes(1)
})
it('keeps another group selected when an earlier save response arrives', async () => {
const firstGroup = routingGroup(['model-a'], {
id: 'group-1',
name: 'First routing',
})
const secondGroup = routingGroup(['model-b'], {
id: 'group-2',
name: 'Second routing',
is_system_default: false,
})
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage([firstGroup, secondGroup])
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-a-new')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.params = { groupId: 'group-2' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...firstGroup,
...submittedPayload,
config_json: submittedPayload.config_json ?? firstGroup.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('Second routing')
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
expect(routeMocks.replace).not.toHaveBeenCalled()
})
it('refreshes a clean draft when returning to the saved group before the response arrives', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfiles'
routeMocks.route.params = {}
await nextTick()
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect((root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement).disabled).toBe(true)
})
it('does not attach an old create response to a recreated draft', async () => {
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileCreate'
routeMocks.route.params = {}
let resolveCreate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupCreateRequest | undefined
await mountPage([])
apiMocks.createRoutingGroup.mockImplementationOnce(
async (payload: RoutingGroupCreateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveCreate = resolve
})
},
)
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
routeMocks.route.name = 'RoutingProfiles'
await nextTick()
routeMocks.route.name = 'RoutingProfileCreate'
await nextTick()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
if (!resolveCreate || !submittedPayload) throw new Error('create request did not start')
const config = submittedPayload.config_json
resolveCreate({
...routingGroup(config?.allowed_models ?? [], {
id: 'created-group',
name: submittedPayload.name,
description: submittedPayload.description,
enabled: submittedPayload.enabled ?? false,
is_system_default: submittedPayload.is_system_default ?? false,
}),
config_json: config ?? routingGroup().config_json,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
expect(routeMocks.replace).not.toHaveBeenCalled()
expect(apiMocks.createRoutingGroup).toHaveBeenCalledTimes(1)
})
})
@@ -151,69 +151,6 @@
</div> </div>
</div> </div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-openai-image-sync-heartbeat"
:checked="enableOpenaiImageSyncHeartbeat"
@update:checked="$emit('update:enableOpenaiImageSyncHeartbeat', $event)"
/>
<div>
<Label
for="enable-openai-image-sync-heartbeat"
class="cursor-pointer"
>
同步生图心跳
</Label>
<p class="text-xs text-muted-foreground">
开启后同步生图外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-standard-text-sync-heartbeat"
:checked="enableStandardTextSyncHeartbeat"
@update:checked="$emit('update:enableStandardTextSyncHeartbeat', $event)"
/>
<div>
<Label
for="enable-standard-text-sync-heartbeat"
class="cursor-pointer"
>
标准文本非流式心跳
</Label>
<p class="text-xs text-muted-foreground">
开启后标准文本非流式接口外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="cyber-continue-failover"
:checked="cyberContinueFailover"
@update:checked="$emit('update:cyberContinueFailover', $event)"
/>
<div>
<Label
for="cyber-continue-failover"
class="cursor-pointer"
>
Cyber继续转移
</Label>
<p class="text-xs text-muted-foreground">
关闭时Cyber Policy错误直接返回客户端;开启后在响应内容开始前按普通错误继续故障转移,可能增加首字等待时间
</p>
</div>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5"> <div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full"> <div class="flex items-center h-full">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -539,9 +476,6 @@ defineProps<{
registrationPrivacyPolicyVersion: string registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
enableStandardTextSyncHeartbeat: boolean
cyberContinueFailover: boolean
loading: boolean loading: boolean
hasChanges: boolean hasChanges: boolean
}>() }>()
@@ -568,8 +502,5 @@ defineEmits<{
'update:registrationPrivacyPolicyVersion': [value: string] 'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean] 'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean] 'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
'update:enableStandardTextSyncHeartbeat': [value: boolean]
'update:cyberContinueFailover': [value: boolean]
}>() }>()
</script> </script>
@@ -59,7 +59,6 @@ describe('useSystemConfig', () => {
resolveConfigs?.([ resolveConfigs?.([
{ key: 'request_record_level', value: 'basic' }, { key: 'request_record_level', value: 'basic' },
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000 }, { key: 'proxy_node_metrics_cleanup_batch_size', value: 5000 },
{ key: 'enable_standard_text_sync_heartbeat', value: false },
]) ])
await loadPromise await loadPromise
@@ -71,50 +70,6 @@ describe('useSystemConfig', () => {
expect(state.hasLogConfigChanges.value).toBe(true) expect(state.hasLogConfigChanges.value).toBe(true)
}) })
it('loads and saves the standard text sync heartbeat flag as a basic config item', async () => {
getAllSystemConfigsMock.mockResolvedValue([
{ key: 'enable_standard_text_sync_heartbeat', value: false },
])
updateSystemConfigMock.mockResolvedValue({})
const state = useSystemConfig()
await state.loadSystemConfig()
expect(state.systemConfig.value.enable_standard_text_sync_heartbeat).toBe(false)
state.systemConfig.value.enable_standard_text_sync_heartbeat = true
expect(state.hasBasicConfigChanges.value).toBe(true)
await state.saveBasicConfig()
expect(updateSystemConfigMock).toHaveBeenCalledWith(
'enable_standard_text_sync_heartbeat',
true,
'标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体'
)
expect(state.hasBasicConfigChanges.value).toBe(false)
})
it('keeps Cyber failover disabled by default and saves the enabled state', async () => {
getAllSystemConfigsMock.mockResolvedValue([])
updateSystemConfigMock.mockResolvedValue({})
const state = useSystemConfig()
await state.loadSystemConfig()
expect(state.systemConfig.value.cyber_continue_failover).toBe(false)
state.systemConfig.value.cyber_continue_failover = true
expect(state.hasBasicConfigChanges.value).toBe(true)
await state.saveBasicConfig()
expect(updateSystemConfigMock).toHaveBeenCalledWith(
'cyber_continue_failover',
true,
'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间'
)
expect(state.hasBasicConfigChanges.value).toBe(false)
})
it('uses backend-compatible defaults when config rows have not been persisted yet', async () => { it('uses backend-compatible defaults when config rows have not been persisted yet', async () => {
getAllSystemConfigsMock.mockResolvedValue([]) getAllSystemConfigsMock.mockResolvedValue([])
@@ -33,12 +33,6 @@ export interface SystemConfig {
auto_delete_expired_keys: boolean auto_delete_expired_keys: boolean
// 格式转换 // 格式转换
enable_format_conversion: boolean enable_format_conversion: boolean
// 同步生图心跳
enable_openai_image_sync_heartbeat: boolean
// 标准文本非流式心跳
enable_standard_text_sync_heartbeat: boolean
// Cyber Policy 错误继续故障转移
cyber_continue_failover: boolean
// 请求记录 // 请求记录
request_record_level: string request_record_level: string
sensitive_headers: string[] sensitive_headers: string[]
@@ -89,12 +83,6 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys', 'auto_delete_expired_keys',
// 格式转换 // 格式转换
'enable_format_conversion', 'enable_format_conversion',
// 同步生图心跳
'enable_openai_image_sync_heartbeat',
// 标准文本非流式心跳
'enable_standard_text_sync_heartbeat',
// Cyber Policy 错误继续故障转移
'cyber_continue_failover',
// 请求记录 // 请求记录
'request_record_level', 'request_record_level',
'sensitive_headers', 'sensitive_headers',
@@ -147,12 +135,6 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false, auto_delete_expired_keys: false,
// 格式转换 // 格式转换
enable_format_conversion: false, enable_format_conversion: false,
// 同步生图心跳
enable_openai_image_sync_heartbeat: false,
// 标准文本非流式心跳
enable_standard_text_sync_heartbeat: false,
// Cyber Policy 错误继续故障转移
cyber_continue_failover: false,
// 请求记录 // 请求记录
request_record_level: 'full', request_record_level: 'full',
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'], sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
@@ -235,13 +217,7 @@ export function useSystemConfig() {
systemConfig.value.registration_privacy_policy_version !== systemConfig.value.registration_privacy_policy_version !==
originalConfig.value.registration_privacy_policy_version || originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys || systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion || systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
systemConfig.value.enable_openai_image_sync_heartbeat !==
originalConfig.value.enable_openai_image_sync_heartbeat ||
systemConfig.value.enable_standard_text_sync_heartbeat !==
originalConfig.value.enable_standard_text_sync_heartbeat ||
systemConfig.value.cyber_continue_failover !==
originalConfig.value.cyber_continue_failover
) )
}) })
@@ -490,21 +466,6 @@ export function useSystemConfig() {
value: systemConfig.value.enable_format_conversion, value: systemConfig.value.enable_format_conversion,
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换', description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
}, },
{
key: 'enable_openai_image_sync_heartbeat',
value: systemConfig.value.enable_openai_image_sync_heartbeat,
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
},
{
key: 'enable_standard_text_sync_heartbeat',
value: systemConfig.value.enable_standard_text_sync_heartbeat,
description: '标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
},
{
key: 'cyber_continue_failover',
value: systemConfig.value.cyber_continue_failover,
description: 'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间',
},
] ]
const turnstileSecret = systemConfig.value.turnstile_secret_key.trim() const turnstileSecret = systemConfig.value.turnstile_secret_key.trim()
if (turnstileSecret) { if (turnstileSecret) {
@@ -555,12 +516,6 @@ export function useSystemConfig() {
systemConfig.value.auto_delete_expired_keys systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion = originalConfig.value.enable_format_conversion =
systemConfig.value.enable_format_conversion systemConfig.value.enable_format_conversion
originalConfig.value.enable_openai_image_sync_heartbeat =
systemConfig.value.enable_openai_image_sync_heartbeat
originalConfig.value.enable_standard_text_sync_heartbeat =
systemConfig.value.enable_standard_text_sync_heartbeat
originalConfig.value.cyber_continue_failover =
systemConfig.value.cyber_continue_failover
} }
success('基础配置已保存') success('基础配置已保存')
} catch (err) { } catch (err) {
+6 -8
View File
@@ -1153,7 +1153,7 @@ AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=self AETHER_UPDATE_STRATEGY=self
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk API_KEY_PREFIX=sk
@@ -1196,7 +1196,7 @@ AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
AETHER_GATEWAY_NODE_ROLE=${role} AETHER_GATEWAY_NODE_ROLE=${role}
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=redis AETHER_RUNTIME_BACKEND=redis
API_KEY_PREFIX=sk API_KEY_PREFIX=sk
@@ -1283,7 +1283,7 @@ generate_compose_env() {
replace_or_append_env "${output}" "AETHER_UPDATE_STRATEGY" "docker" replace_or_append_env "${output}" "AETHER_UPDATE_STRATEGY" "docker"
replace_or_append_env "${output}" "AETHER_DOCKER_UPDATE_COMMAND" "./update.sh" replace_or_append_env "${output}" "AETHER_DOCKER_UPDATE_COMMAND" "./update.sh"
append_compose_log_env_defaults "${output}" append_compose_log_env_defaults "${output}"
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true" replace_or_append_env "${output}" "AETHER_GATEWAY_DATABASE_MODE" "auto"
} }
generate_compose_single_node_env() { generate_compose_single_node_env() {
@@ -1305,7 +1305,7 @@ AETHER_UPDATE_STRATEGY=docker
AETHER_DOCKER_UPDATE_COMMAND=./update.sh AETHER_DOCKER_UPDATE_COMMAND=./update.sh
AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR} AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR}
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk API_KEY_PREFIX=sk
@@ -1945,8 +1945,7 @@ EOF
cat <<EOF cat <<EOF
Database: Database:
empty database: first service start auto-bootstraps to the current baseline schema migrations and data backfills are prepared automatically before startup
later schema upgrades: ${INSTALL_ROOT}/current/bin/aether-gateway --migrate
Current release: Current release:
${INSTALL_ROOT}/current ${INSTALL_ROOT}/current
@@ -2129,8 +2128,7 @@ EOF
cat <<EOF cat <<EOF
Database: Database:
empty database: first service start auto-bootstraps to the current baseline schema migrations and data backfills are prepared automatically before startup
later schema upgrades: ${INSTALL_ROOT}/current/bin/aether-gateway --migrate
Current release: Current release:
${INSTALL_ROOT}/current ${INSTALL_ROOT}/current