feat: add routing profile scheduling policies

This commit is contained in:
fawney19
2026-05-18 11:03:49 +08:00
parent a2f91b4108
commit 92813e6122
124 changed files with 11681 additions and 578 deletions

View File

@@ -8,6 +8,7 @@ description = "Shared data contracts and repository traits for Aether Rust servi
[dependencies]
aether-ai-formats.workspace = true
aether-routing-core.workspace = true
async-trait.workspace = true
chrono.workspace = true
serde.workspace = true

View File

@@ -6,6 +6,7 @@ pub mod global_models;
pub mod pool_scores;
pub mod provider_catalog;
pub mod quota;
pub mod routing_profiles;
pub mod settlement;
pub mod usage;
pub mod video_tasks;

View File

@@ -0,0 +1,9 @@
mod types;
pub use aether_routing_core::{RoutingGroupBindingSubject, RoutingGroupConfig, RoutingGroupRecord};
pub use types::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupLookupKey, RoutingGroupReadRepository,
RoutingGroupWriteRepository, StoredRoutingGroup, StoredRoutingGroupBinding,
StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
};

View File

@@ -0,0 +1,238 @@
use async_trait::async_trait;
use serde_json::Value;
use aether_routing_core::RoutingGroupBindingSubject;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredRoutingGroup {
pub id: String,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub is_system_default: bool,
pub config_json: Value,
pub version: i64,
pub created_at: i64,
pub updated_at: i64,
pub published_at: Option<i64>,
}
impl StoredRoutingGroup {
pub fn new(record: CreateRoutingGroupRecord) -> Result<Self, crate::DataLayerError> {
validate_non_empty(&record.id, "routing_groups.id")?;
validate_non_empty(&record.name, "routing_groups.name")?;
validate_config_object(&record.config_json, "routing_groups.config_json")?;
Ok(Self {
id: record.id,
name: record.name,
description: record.description,
enabled: record.enabled,
is_system_default: record.is_system_default,
config_json: record.config_json,
version: record.version.max(1),
created_at: record.created_at,
updated_at: record.updated_at,
published_at: record.published_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateRoutingGroupRecord {
pub id: String,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub is_system_default: bool,
pub config_json: Value,
pub version: i64,
pub created_at: i64,
pub updated_at: i64,
pub published_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UpdateRoutingGroupRecord {
pub name: Option<String>,
pub description: Option<Option<String>>,
pub enabled: Option<bool>,
pub is_system_default: Option<bool>,
pub config_json: Option<Value>,
pub version: Option<i64>,
pub updated_at: i64,
pub published_at: Option<Option<i64>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UpdateRoutingGroupBindingRecord {
pub group_id: Option<String>,
pub subject_type: Option<RoutingGroupBindingSubject>,
pub subject_id: Option<String>,
pub is_default: Option<bool>,
pub allow_explicit_select: Option<bool>,
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredRoutingGroupBinding {
pub id: String,
pub group_id: String,
pub subject_type: RoutingGroupBindingSubject,
pub subject_id: String,
pub is_default: bool,
pub allow_explicit_select: bool,
pub created_at: i64,
pub updated_at: i64,
}
impl StoredRoutingGroupBinding {
pub fn new(record: CreateRoutingGroupBindingRecord) -> Result<Self, crate::DataLayerError> {
validate_non_empty(&record.id, "routing_group_bindings.id")?;
validate_non_empty(&record.group_id, "routing_group_bindings.group_id")?;
validate_non_empty(&record.subject_id, "routing_group_bindings.subject_id")?;
Ok(Self {
id: record.id,
group_id: record.group_id,
subject_type: record.subject_type,
subject_id: record.subject_id,
is_default: record.is_default,
allow_explicit_select: record.allow_explicit_select,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateRoutingGroupBindingRecord {
pub id: String,
pub group_id: String,
pub subject_type: RoutingGroupBindingSubject,
pub subject_id: String,
pub is_default: bool,
pub allow_explicit_select: bool,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredRoutingGroupVersion {
pub id: String,
pub group_id: String,
pub version: i64,
pub config_json: Value,
pub created_at: i64,
pub created_by: Option<String>,
}
impl StoredRoutingGroupVersion {
pub fn new(record: CreateRoutingGroupVersionRecord) -> Result<Self, crate::DataLayerError> {
validate_non_empty(&record.id, "routing_group_versions.id")?;
validate_non_empty(&record.group_id, "routing_group_versions.group_id")?;
validate_config_object(&record.config_json, "routing_group_versions.config_json")?;
Ok(Self {
id: record.id,
group_id: record.group_id,
version: record.version.max(1),
config_json: record.config_json,
created_at: record.created_at,
created_by: record.created_by,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateRoutingGroupVersionRecord {
pub id: String,
pub group_id: String,
pub version: i64,
pub config_json: Value,
pub created_at: i64,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutingGroupLookupKey<'a> {
Id(&'a str),
Name(&'a str),
SystemDefault,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RoutingGroupBindingQuery {
pub group_id: Option<String>,
pub subject_type: Option<RoutingGroupBindingSubject>,
pub subject_id: Option<String>,
}
#[async_trait]
pub trait RoutingGroupReadRepository: Send + Sync {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, crate::DataLayerError>;
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, crate::DataLayerError>;
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, crate::DataLayerError>;
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, crate::DataLayerError>;
}
#[async_trait]
pub trait RoutingGroupWriteRepository: Send + Sync {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, crate::DataLayerError>;
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, crate::DataLayerError>;
async fn delete_routing_group(&self, id: &str) -> Result<bool, crate::DataLayerError>;
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, crate::DataLayerError>;
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, crate::DataLayerError>;
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, crate::DataLayerError>;
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, crate::DataLayerError>;
}
fn validate_non_empty(value: &str, field: &str) -> Result<(), crate::DataLayerError> {
if value.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(format!(
"{field} is empty"
)));
}
Ok(())
}
fn validate_config_object(value: &Value, field: &str) -> Result<(), crate::DataLayerError> {
if !value.is_object() {
return Err(crate::DataLayerError::InvalidInput(format!(
"{field} must be a JSON object"
)));
}
Ok(())
}

View File

@@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS routing_groups (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` LONGTEXT,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_system_default` TINYINT(1) NOT NULL DEFAULT 0,
`config_json` JSON NOT NULL,
`version` BIGINT NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
`published_at` BIGINT,
PRIMARY KEY (`id`),
UNIQUE KEY routing_groups_name_key (`name`),
KEY routing_groups_system_default_idx (`is_system_default`, `enabled`)
);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_id` VARCHAR(64) NOT NULL,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`allow_explicit_select` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY routing_group_bindings_group_id_idx (`group_id`),
KEY routing_group_bindings_subject_idx (`subject_type`, `subject_id`)
);
CREATE TABLE IF NOT EXISTS routing_group_versions (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`version` BIGINT NOT NULL,
`config_json` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`created_by` VARCHAR(64),
PRIMARY KEY (`id`),
UNIQUE KEY routing_group_versions_group_version_key (`group_id`, `version`),
KEY routing_group_versions_group_id_idx (`group_id`)
);

View File

@@ -0,0 +1,82 @@
CREATE TABLE IF NOT EXISTS public.routing_groups (
id character varying(64) NOT NULL,
name character varying(255) NOT NULL,
description text,
enabled boolean DEFAULT true NOT NULL,
is_system_default boolean DEFAULT false NOT NULL,
config_json jsonb NOT NULL,
version bigint DEFAULT 1 NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
published_at bigint
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_groups_pkey'
) THEN
ALTER TABLE ONLY public.routing_groups
ADD CONSTRAINT routing_groups_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_groups_name_key'
) THEN
ALTER TABLE ONLY public.routing_groups
ADD CONSTRAINT routing_groups_name_key UNIQUE (name);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx
ON public.routing_groups USING btree (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS public.routing_group_bindings (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
subject_type character varying(32) NOT NULL,
subject_id character varying(64) NOT NULL,
is_default boolean DEFAULT false NOT NULL,
allow_explicit_select boolean DEFAULT true NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_bindings_pkey'
) THEN
ALTER TABLE ONLY public.routing_group_bindings
ADD CONSTRAINT routing_group_bindings_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx
ON public.routing_group_bindings USING btree (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx
ON public.routing_group_bindings USING btree (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS public.routing_group_versions (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
version bigint NOT NULL,
config_json jsonb NOT NULL,
created_at bigint NOT NULL,
created_by character varying(64)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_versions_pkey'
) THEN
ALTER TABLE ONLY public.routing_group_versions
ADD CONSTRAINT routing_group_versions_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_versions_group_version_key'
) THEN
ALTER TABLE ONLY public.routing_group_versions
ADD CONSTRAINT routing_group_versions_group_version_key UNIQUE (group_id, version);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON public.routing_group_versions USING btree (group_id);

View File

@@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS routing_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
is_system_default INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
UNIQUE (name)
);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx
ON routing_groups (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
allow_explicit_select INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx
ON routing_group_bindings (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx
ON routing_group_bindings (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS routing_group_versions (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
version INTEGER NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT,
UNIQUE (group_id, version)
);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON routing_group_versions (group_id);

View File

@@ -383,3 +383,45 @@ CREATE TABLE IF NOT EXISTS global_models (
UNIQUE KEY global_models_name_key (`name`)
);
CREATE TABLE IF NOT EXISTS routing_groups (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` LONGTEXT,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_system_default` TINYINT(1) NOT NULL DEFAULT 0,
`config_json` JSON NOT NULL,
`version` BIGINT NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
`published_at` BIGINT,
PRIMARY KEY (`id`),
UNIQUE KEY routing_groups_name_key (`name`),
KEY routing_groups_system_default_idx (`is_system_default`, `enabled`)
);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_id` VARCHAR(64) NOT NULL,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`allow_explicit_select` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY routing_group_bindings_group_id_idx (`group_id`),
KEY routing_group_bindings_subject_idx (`subject_type`, `subject_id`)
);
CREATE TABLE IF NOT EXISTS routing_group_versions (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`version` BIGINT NOT NULL,
`config_json` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`created_by` VARCHAR(64),
PRIMARY KEY (`id`),
UNIQUE KEY routing_group_versions_group_version_key (`group_id`, `version`),
KEY routing_group_versions_group_id_idx (`group_id`)
);

View File

@@ -396,3 +396,48 @@ CREATE TABLE IF NOT EXISTS public.global_models (
ALTER TABLE ONLY public.global_models ADD CONSTRAINT global_models_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.global_models ADD CONSTRAINT global_models_name_key UNIQUE (name);
CREATE TABLE IF NOT EXISTS public.routing_groups (
id character varying(64) NOT NULL,
name character varying(255) NOT NULL,
description text,
enabled boolean DEFAULT true NOT NULL,
is_system_default boolean DEFAULT false NOT NULL,
config_json jsonb NOT NULL,
version bigint DEFAULT 1 NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
published_at bigint
);
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);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON public.routing_groups USING btree (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS public.routing_group_bindings (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
subject_type character varying(32) NOT NULL,
subject_id character varying(64) NOT NULL,
is_default boolean DEFAULT false NOT NULL,
allow_explicit_select boolean DEFAULT true NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
ALTER TABLE ONLY public.routing_group_bindings ADD CONSTRAINT routing_group_bindings_pkey PRIMARY KEY (id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx ON public.routing_group_bindings USING btree (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx ON public.routing_group_bindings USING btree (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS public.routing_group_versions (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
version bigint NOT NULL,
config_json jsonb NOT NULL,
created_at bigint NOT NULL,
created_by character varying(64)
);
ALTER TABLE ONLY public.routing_group_versions ADD CONSTRAINT routing_group_versions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.routing_group_versions ADD CONSTRAINT routing_group_versions_group_version_key UNIQUE (group_id, version);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx ON public.routing_group_versions USING btree (group_id);

View File

@@ -370,3 +370,42 @@ CREATE TABLE IF NOT EXISTS global_models (
UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS routing_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
is_system_default INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
UNIQUE (name)
);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON routing_groups (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
allow_explicit_select INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx ON routing_group_bindings (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx ON routing_group_bindings (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS routing_group_versions (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
version INTEGER NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT,
UNIQUE (group_id, version)
);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx ON routing_group_versions (group_id);

View File

@@ -1694,3 +1694,155 @@ type = "unix_seconds"
[[table.global_models.uniques]]
name = "global_models_name_key"
columns = ["name"]
[table.routing_groups]
domain = "provider_catalog"
order = 110
primary_key = ["id"]
[[table.routing_groups.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_groups.columns]]
name = "name"
type = "text"
length = 255
[[table.routing_groups.columns]]
name = "description"
type = "long_text"
nullable = true
[[table.routing_groups.columns]]
name = "enabled"
type = "bool"
default = true
[[table.routing_groups.columns]]
name = "is_system_default"
type = "bool"
default = false
[[table.routing_groups.columns]]
name = "config_json"
type = "json"
[[table.routing_groups.columns]]
name = "version"
type = "int64"
default = 1
[[table.routing_groups.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_groups.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.routing_groups.columns]]
name = "published_at"
type = "unix_seconds"
nullable = true
[[table.routing_groups.uniques]]
name = "routing_groups_name_key"
columns = ["name"]
[[table.routing_groups.indexes]]
name = "routing_groups_system_default_idx"
columns = ["is_system_default", "enabled"]
[table.routing_group_bindings]
domain = "provider_catalog"
order = 111
primary_key = ["id"]
[[table.routing_group_bindings.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "group_id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "subject_type"
type = "text"
length = 32
[[table.routing_group_bindings.columns]]
name = "subject_id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "is_default"
type = "bool"
default = false
[[table.routing_group_bindings.columns]]
name = "allow_explicit_select"
type = "bool"
default = true
[[table.routing_group_bindings.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_group_bindings.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.routing_group_bindings.indexes]]
name = "routing_group_bindings_group_id_idx"
columns = ["group_id"]
[[table.routing_group_bindings.indexes]]
name = "routing_group_bindings_subject_idx"
columns = ["subject_type", "subject_id"]
[table.routing_group_versions]
domain = "provider_catalog"
order = 112
primary_key = ["id"]
[[table.routing_group_versions.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_group_versions.columns]]
name = "group_id"
type = "text_id"
length = 64
[[table.routing_group_versions.columns]]
name = "version"
type = "int64"
[[table.routing_group_versions.columns]]
name = "config_json"
type = "json"
[[table.routing_group_versions.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_group_versions.columns]]
name = "created_by"
type = "text_id"
length = 64
nullable = true
[[table.routing_group_versions.uniques]]
name = "routing_group_versions_group_version_key"
columns = ["group_id", "version"]
[[table.routing_group_versions.indexes]]
name = "routing_group_versions_group_id_idx"
columns = ["group_id"]

View File

@@ -50,6 +50,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
MysqlProviderQuotaRepository, ProviderQuotaReadRepository, ProviderQuotaWriteRepository,
};
use crate::repository::routing_profiles::{
MysqlRoutingGroupRepository, RoutingGroupReadRepository, RoutingGroupWriteRepository,
};
use crate::repository::settlement::{MysqlSettlementRepository, SettlementWriteRepository};
use crate::repository::usage::{
MysqlUsageReadRepository, MysqlUsageWriteRepository, UsageReadRepository, UsageWriteRepository,
@@ -195,6 +198,14 @@ impl MysqlBackend {
Arc::new(MysqlPoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(MysqlRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(MysqlRoutingGroupRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(MysqlProxyNodeReadRepository::new(self.pool_clone()))
}

View File

@@ -52,6 +52,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqlxProviderQuotaRepository,
};
use crate::repository::routing_profiles::{
PostgresRoutingGroupRepository, RoutingGroupReadRepository, RoutingGroupWriteRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqlxSettlementRepository};
use crate::repository::usage::{
SqlxUsageReadRepository, UsageReadRepository, UsageWriteRepository,
@@ -206,6 +209,14 @@ impl PostgresBackend {
Arc::new(PostgresPoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(PostgresRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(PostgresRoutingGroupRepository::new(self.pool_clone()))
}
pub fn provider_quota_read_repository(&self) -> Arc<dyn ProviderQuotaReadRepository> {
Arc::new(SqlxProviderQuotaRepository::new(self.pool_clone()))
}

View File

@@ -18,6 +18,7 @@ use crate::repository::pool_scores::PoolScoreReadRepository;
use crate::repository::provider_catalog::ProviderCatalogReadRepository;
use crate::repository::proxy_nodes::ProxyNodeReadRepository;
use crate::repository::quota::ProviderQuotaReadRepository;
use crate::repository::routing_profiles::RoutingGroupReadRepository;
use crate::repository::usage::UsageReadRepository;
use crate::repository::users::UserReadRepository;
use crate::repository::video_tasks::VideoTaskReadRepository;
@@ -41,6 +42,7 @@ pub struct DataReadRepositories {
request_candidates: Option<Arc<dyn RequestCandidateReadRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogReadRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaReadRepository>>,
routing_groups: Option<Arc<dyn RoutingGroupReadRepository>>,
usage: Option<Arc<dyn UsageReadRepository>>,
users: Option<Arc<dyn UserReadRepository>>,
video_tasks: Option<Arc<dyn VideoTaskReadRepository>>,
@@ -72,6 +74,7 @@ impl fmt::Debug for DataReadRepositories {
.field("has_request_candidates", &self.request_candidates.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_routing_groups", &self.routing_groups.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_users", &self.users.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
@@ -151,6 +154,10 @@ impl DataReadRepositories {
.map(PostgresBackend::provider_quota_read_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_read_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_read_repository)),
routing_groups: postgres
.map(PostgresBackend::routing_group_read_repository)
.or_else(|| mysql.map(MysqlBackend::routing_group_read_repository))
.or_else(|| sqlite.map(SqliteBackend::routing_group_read_repository)),
usage: postgres
.map(PostgresBackend::usage_read_repository)
.or_else(|| mysql.map(MysqlBackend::usage_read_repository))
@@ -241,6 +248,10 @@ impl DataReadRepositories {
self.provider_quotas.clone()
}
pub fn routing_groups(&self) -> Option<Arc<dyn RoutingGroupReadRepository>> {
self.routing_groups.clone()
}
pub fn usage(&self) -> Option<Arc<dyn UsageReadRepository>> {
self.usage.clone()
}
@@ -274,6 +285,7 @@ impl DataReadRepositories {
|| self.request_candidates.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.routing_groups.is_some()
|| self.usage.is_some()
|| self.users.is_some()
|| self.video_tasks.is_some()

View File

@@ -50,6 +50,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqliteProviderQuotaRepository,
};
use crate::repository::routing_profiles::{
RoutingGroupReadRepository, RoutingGroupWriteRepository, SqliteRoutingGroupRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqliteSettlementRepository};
use crate::repository::usage::{
SqliteUsageReadRepository, SqliteUsageWriteRepository, UsageReadRepository,
@@ -208,6 +211,14 @@ impl SqliteBackend {
Arc::new(SqlitePoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(SqliteRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(SqliteRoutingGroupRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(SqliteProxyNodeReadRepository::new(self.pool_clone()))
}

View File

@@ -15,6 +15,7 @@ use crate::repository::pool_scores::PoolMemberScoreWriteRepository;
use crate::repository::provider_catalog::ProviderCatalogWriteRepository;
use crate::repository::proxy_nodes::ProxyNodeWriteRepository;
use crate::repository::quota::ProviderQuotaWriteRepository;
use crate::repository::routing_profiles::RoutingGroupWriteRepository;
use crate::repository::settlement::SettlementWriteRepository;
use crate::repository::usage::UsageWriteRepository;
use crate::repository::video_tasks::VideoTaskWriteRepository;
@@ -35,6 +36,7 @@ pub struct DataWriteRepositories {
proxy_nodes: Option<Arc<dyn ProxyNodeWriteRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogWriteRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaWriteRepository>>,
routing_groups: Option<Arc<dyn RoutingGroupWriteRepository>>,
settlement: Option<Arc<dyn SettlementWriteRepository>>,
usage: Option<Arc<dyn UsageWriteRepository>>,
video_tasks: Option<Arc<dyn VideoTaskWriteRepository>>,
@@ -60,6 +62,7 @@ impl fmt::Debug for DataWriteRepositories {
.field("has_proxy_nodes", &self.proxy_nodes.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_routing_groups", &self.routing_groups.is_some())
.field("has_settlement", &self.settlement.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
@@ -127,6 +130,10 @@ impl DataWriteRepositories {
.map(PostgresBackend::provider_quota_write_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_write_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_write_repository)),
routing_groups: postgres
.map(PostgresBackend::routing_group_write_repository)
.or_else(|| mysql.map(MysqlBackend::routing_group_write_repository))
.or_else(|| sqlite.map(SqliteBackend::routing_group_write_repository)),
settlement: postgres
.map(PostgresBackend::settlement_write_repository)
.or_else(|| mysql.map(MysqlBackend::settlement_write_repository))
@@ -203,6 +210,10 @@ impl DataWriteRepositories {
self.provider_quotas.clone()
}
pub fn routing_groups(&self) -> Option<Arc<dyn RoutingGroupWriteRepository>> {
self.routing_groups.clone()
}
pub fn provider_catalog(&self) -> Option<Arc<dyn ProviderCatalogWriteRepository>> {
self.provider_catalog.clone()
}
@@ -233,6 +244,7 @@ impl DataWriteRepositories {
|| self.proxy_nodes.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.routing_groups.is_some()
|| self.settlement.is_some()
|| self.usage.is_some()
|| self.video_tasks.is_some()

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260515000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260516000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT
@@ -29,6 +29,7 @@ WHERE table_schema = 'public'
'oauth_providers',
'provider_api_keys',
'proxy_nodes',
'routing_groups',
'user_groups',
'usage_routing_snapshots',
'usage_settlement_snapshots'

View File

@@ -305,6 +305,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260512090000,
20260512110000,
20260515000000,
20260516000000,
]
);
}
@@ -587,6 +588,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512000000,
20260512090000,
20260512110000,
20260516000000,
]
);
assert_eq!(
@@ -603,6 +605,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512000000,
20260512090000,
20260512110000,
20260516000000,
]
);
}
@@ -1119,6 +1122,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260512090000,
20260512110000,
20260515000000,
20260516000000,
]
);
}

View File

@@ -21,6 +21,7 @@ pub mod provider_catalog;
pub mod provider_oauth;
pub mod proxy_nodes;
pub mod quota;
pub mod routing_profiles;
pub mod settlement;
pub mod system;
pub mod usage;

View File

@@ -0,0 +1,342 @@
use std::collections::BTreeMap;
use std::sync::RwLock;
use async_trait::async_trait;
use super::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupLookupKey, RoutingGroupReadRepository,
RoutingGroupWriteRepository, StoredRoutingGroup, StoredRoutingGroupBinding,
StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
};
use crate::DataLayerError;
#[derive(Debug, Default)]
pub struct InMemoryRoutingGroupRepository {
groups: RwLock<BTreeMap<String, StoredRoutingGroup>>,
bindings: RwLock<BTreeMap<String, StoredRoutingGroupBinding>>,
versions: RwLock<BTreeMap<String, StoredRoutingGroupVersion>>,
}
impl InMemoryRoutingGroupRepository {
pub fn seed<I, B, V>(groups: I, bindings: B, versions: V) -> Self
where
I: IntoIterator<Item = StoredRoutingGroup>,
B: IntoIterator<Item = StoredRoutingGroupBinding>,
V: IntoIterator<Item = StoredRoutingGroupVersion>,
{
Self {
groups: RwLock::new(
groups
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
bindings: RwLock::new(
bindings
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
versions: RwLock::new(
versions
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
}
}
}
#[async_trait]
impl RoutingGroupReadRepository for InMemoryRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let mut groups = self
.groups
.read()
.expect("routing group repository lock")
.values()
.cloned()
.collect::<Vec<_>>();
groups.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
Ok(groups)
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let groups = self.groups.read().expect("routing group repository lock");
Ok(match lookup {
RoutingGroupLookupKey::Id(id) => groups.get(id).cloned(),
RoutingGroupLookupKey::Name(name) => {
groups.values().find(|group| group.name == name).cloned()
}
RoutingGroupLookupKey::SystemDefault => groups
.values()
.find(|group| group.is_system_default && group.enabled)
.cloned(),
})
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let mut rows = self
.bindings
.read()
.expect("routing group binding repository lock")
.values()
.filter(|row| {
query
.group_id
.as_ref()
.is_none_or(|group_id| &row.group_id == group_id)
&& query
.subject_type
.as_ref()
.is_none_or(|subject_type| &row.subject_type == subject_type)
&& query
.subject_id
.as_ref()
.is_none_or(|subject_id| &row.subject_id == subject_id)
})
.cloned()
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then(left.id.cmp(&right.id))
});
Ok(rows)
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let mut rows = self
.versions
.read()
.expect("routing group version repository lock")
.values()
.filter(|row| row.group_id == group_id)
.cloned()
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
right
.version
.cmp(&left.version)
.then(right.created_at.cmp(&left.created_at))
});
Ok(rows)
}
}
#[async_trait]
impl RoutingGroupWriteRepository for InMemoryRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
self.groups
.write()
.expect("routing group repository lock")
.insert(group.id.clone(), group.clone());
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let mut groups = self.groups.write().expect("routing group repository lock");
let Some(group) = groups.get_mut(id) else {
return Ok(None);
};
if let Some(name) = patch.name {
if name.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_groups.name is empty".to_string(),
));
}
group.name = name;
}
if let Some(description) = patch.description {
group.description = description;
}
if let Some(enabled) = patch.enabled {
group.enabled = enabled;
}
if let Some(is_system_default) = patch.is_system_default {
group.is_system_default = is_system_default;
}
if let Some(config_json) = patch.config_json {
if !config_json.is_object() {
return Err(DataLayerError::InvalidInput(
"routing_groups.config_json must be a JSON object".to_string(),
));
}
group.config_json = config_json;
}
if let Some(version) = patch.version {
group.version = version.max(1);
}
if let Some(published_at) = patch.published_at {
group.published_at = published_at;
}
group.updated_at = patch.updated_at;
Ok(Some(group.clone()))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(self
.groups
.write()
.expect("routing group repository lock")
.remove(id)
.is_some())
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
self.bindings
.write()
.expect("routing group binding repository lock")
.insert(binding.id.clone(), binding.clone());
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(self
.bindings
.write()
.expect("routing group binding repository lock")
.remove(id)
.is_some())
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let mut bindings = self
.bindings
.write()
.expect("routing group binding repository lock");
let Some(binding) = bindings.get_mut(id) else {
return Ok(None);
};
if let Some(group_id) = patch.group_id {
if group_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.group_id is empty".to_string(),
));
}
binding.group_id = group_id;
}
if let Some(subject_type) = patch.subject_type {
binding.subject_type = subject_type;
}
if let Some(subject_id) = patch.subject_id {
if subject_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.subject_id is empty".to_string(),
));
}
binding.subject_id = subject_id;
}
if let Some(is_default) = patch.is_default {
binding.is_default = is_default;
}
if let Some(allow_explicit_select) = patch.allow_explicit_select {
binding.allow_explicit_select = allow_explicit_select;
}
binding.updated_at = patch.updated_at;
Ok(Some(binding.clone()))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
self.versions
.write()
.expect("routing group version repository lock")
.insert(version.id.clone(), version.clone());
Ok(version)
}
}
#[cfg(test)]
mod tests {
use aether_data_contracts::repository::routing_profiles::RoutingGroupBindingSubject;
use serde_json::json;
use super::*;
#[tokio::test]
async fn stores_groups_bindings_and_versions() {
let repository = InMemoryRoutingGroupRepository::default();
let group = repository
.create_routing_group(CreateRoutingGroupRecord {
id: "group-1".to_string(),
name: "default".to_string(),
description: None,
enabled: true,
is_system_default: true,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("group should store");
assert_eq!(
repository
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
.await
.unwrap()
.as_ref()
.map(|group| group.id.as_str()),
Some(group.id.as_str())
);
repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-1".to_string(),
group_id: "group-1".to_string(),
subject_type: RoutingGroupBindingSubject::ApiKey,
subject_id: "api-key-1".to_string(),
is_default: true,
allow_explicit_select: true,
created_at: 1,
updated_at: 1,
})
.await
.unwrap();
assert_eq!(
repository
.list_routing_group_bindings(&RoutingGroupBindingQuery {
subject_type: Some(RoutingGroupBindingSubject::ApiKey),
subject_id: Some("api-key-1".to_string()),
group_id: None,
})
.await
.unwrap()
.len(),
1
);
}
}

View File

@@ -0,0 +1,16 @@
mod memory;
mod mysql;
mod postgres;
mod sqlite;
pub(crate) use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupBindingSubject, RoutingGroupLookupKey,
RoutingGroupReadRepository, RoutingGroupWriteRepository, StoredRoutingGroup,
StoredRoutingGroupBinding, StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord,
UpdateRoutingGroupRecord,
};
pub use memory::InMemoryRoutingGroupRepository;
pub use mysql::MysqlRoutingGroupRepository;
pub use postgres::PostgresRoutingGroupRepository;
pub use sqlite::SqliteRoutingGroupRepository;

View File

@@ -0,0 +1,417 @@
use async_trait::async_trait;
use serde_json::Value;
use sqlx::{mysql::MySqlRow, Row};
use super::postgres::{
apply_binding_patch, apply_group_patch, binding_subject_from_database,
binding_subject_to_database,
};
use super::*;
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct MysqlRoutingGroupRepository {
pool: MysqlPool,
}
impl MysqlRoutingGroupRepository {
pub fn new(pool: MysqlPool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for MysqlRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC"))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect()
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = ? LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = 1 AND enabled = 1 ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE (? IS NULL OR group_id = ?)
AND (? IS NULL OR subject_type = ?)
AND (? IS NULL OR subject_id = ?)
ORDER BY created_at ASC, id ASC
"#
))
.bind(query.group_id.as_deref())
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.bind(query.subject_id.as_deref())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_binding_row).collect()
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let rows = sqlx::query(&format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = ? ORDER BY version DESC, created_at DESC, id ASC"
))
.bind(group_id)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_version_row).collect()
}
}
#[async_trait]
impl RoutingGroupWriteRepository for MysqlRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = ?,
description = ?,
enabled = ?,
is_system_default = ?,
config_json = ?,
version = ?,
updated_at = ?,
published_at = ?
WHERE id = ?
"#,
)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = ?,
subject_type = ?,
subject_id = ?,
is_default = ?,
allow_explicit_select = ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(json_to_string(
&version.config_json,
"routing_group_versions.config_json",
)?)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(version)
}
}
fn map_group_row(row: &MySqlRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_sql_err()?,
name: row.try_get("name").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json",
)?,
version: row.try_get("version").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
published_at: row.try_get("published_at").map_sql_err()?,
})
}
fn map_binding_row(row: &MySqlRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
subject_type: binding_subject_from_database(row.try_get("subject_type").map_sql_err()?)?,
subject_id: row.try_get("subject_id").map_sql_err()?,
is_default: row.try_get("is_default").map_sql_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
})
}
fn map_version_row(row: &MySqlRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
version: row.try_get("version").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_group_versions.config_json",
)?,
created_at: row.try_get("created_at").map_sql_err()?,
created_by: row.try_get("created_by").map_sql_err()?,
})
}
fn json_to_string(value: &Value, field_name: &str) -> Result<String, DataLayerError> {
serde_json::to_string(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains unserializable JSON: {err}"))
})
}
fn json_from_string(value: String, field_name: &str) -> Result<Value, DataLayerError> {
serde_json::from_str(&value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains invalid JSON: {err}"))
})
}

View File

@@ -0,0 +1,483 @@
use async_trait::async_trait;
use futures_util::TryStreamExt;
use sqlx::{postgres::PgRow, PgPool, Row};
use super::*;
use crate::error::SqlxResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct PostgresRoutingGroupRepository {
pool: PgPool,
}
impl PostgresRoutingGroupRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = $1 LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for PostgresRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let sql = format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC");
let mut rows = sqlx::query(&sql).fetch(&self.pool);
let mut groups = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
groups.push(map_group_row(&row)?);
}
Ok(groups)
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = $1 LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = $1 LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = TRUE AND enabled = TRUE ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let sql = format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE ($1::text IS NULL OR group_id = $1)
AND ($2::text IS NULL OR subject_type = $2)
AND ($3::text IS NULL OR subject_id = $3)
ORDER BY created_at ASC, id ASC
"#
);
let mut rows = sqlx::query(&sql)
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.fetch(&self.pool);
let mut bindings = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
bindings.push(map_binding_row(&row)?);
}
Ok(bindings)
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let sql = format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = $1 ORDER BY version DESC, created_at DESC, id ASC"
);
let mut rows = sqlx::query(&sql).bind(group_id).fetch(&self.pool);
let mut versions = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
versions.push(map_version_row(&row)?);
}
Ok(versions)
}
}
#[async_trait]
impl RoutingGroupWriteRepository for PostgresRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(&group.config_json)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = $2,
description = $3,
enabled = $4,
is_system_default = $5,
config_json = $6,
version = $7,
updated_at = $8,
published_at = $9
WHERE id = $1
"#,
)
.bind(id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(&group.config_json)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_postgres_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?
.rows_affected();
tx.commit().await.map_postgres_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_postgres_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = $2,
subject_type = $3,
subject_id = $4,
is_default = $5,
allow_explicit_select = $6,
updated_at = $7
WHERE id = $1
"#,
)
.bind(id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(&version.config_json)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(version)
}
}
pub(super) fn apply_group_patch(
group: &mut StoredRoutingGroup,
patch: UpdateRoutingGroupRecord,
) -> Result<(), DataLayerError> {
if let Some(name) = patch.name {
if name.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_groups.name is empty".to_string(),
));
}
group.name = name;
}
if let Some(description) = patch.description {
group.description = description;
}
if let Some(enabled) = patch.enabled {
group.enabled = enabled;
}
if let Some(is_system_default) = patch.is_system_default {
group.is_system_default = is_system_default;
}
if let Some(config_json) = patch.config_json {
if !config_json.is_object() {
return Err(DataLayerError::InvalidInput(
"routing_groups.config_json must be a JSON object".to_string(),
));
}
group.config_json = config_json;
}
if let Some(version) = patch.version {
group.version = version.max(1);
}
if let Some(published_at) = patch.published_at {
group.published_at = published_at;
}
group.updated_at = patch.updated_at;
Ok(())
}
pub(super) fn apply_binding_patch(
binding: &mut StoredRoutingGroupBinding,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<(), DataLayerError> {
if let Some(group_id) = patch.group_id {
if group_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.group_id is empty".to_string(),
));
}
binding.group_id = group_id;
}
if let Some(subject_type) = patch.subject_type {
binding.subject_type = subject_type;
}
if let Some(subject_id) = patch.subject_id {
if subject_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.subject_id is empty".to_string(),
));
}
binding.subject_id = subject_id;
}
if let Some(is_default) = patch.is_default {
binding.is_default = is_default;
}
if let Some(allow_explicit_select) = patch.allow_explicit_select {
binding.allow_explicit_select = allow_explicit_select;
}
binding.updated_at = patch.updated_at;
Ok(())
}
pub(super) fn binding_subject_to_database(subject: RoutingGroupBindingSubject) -> &'static str {
match subject {
RoutingGroupBindingSubject::User => "user",
RoutingGroupBindingSubject::ApiKey => "api_key",
RoutingGroupBindingSubject::UserGroup => "user_group",
}
}
pub(super) fn binding_subject_from_database(
value: String,
) -> Result<RoutingGroupBindingSubject, DataLayerError> {
match value.as_str() {
"user" => Ok(RoutingGroupBindingSubject::User),
"api_key" => Ok(RoutingGroupBindingSubject::ApiKey),
"user_group" => Ok(RoutingGroupBindingSubject::UserGroup),
_ => Err(DataLayerError::UnexpectedValue(format!(
"invalid routing_group_bindings.subject_type: {value}"
))),
}
}
fn map_group_row(row: &PgRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_postgres_err()?,
name: row.try_get("name").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
is_system_default: row.try_get("is_system_default").map_postgres_err()?,
config_json: row.try_get("config_json").map_postgres_err()?,
version: row.try_get("version").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
updated_at: row.try_get("updated_at").map_postgres_err()?,
published_at: row.try_get("published_at").map_postgres_err()?,
})
}
fn map_binding_row(row: &PgRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_postgres_err()?,
group_id: row.try_get("group_id").map_postgres_err()?,
subject_type: binding_subject_from_database(
row.try_get("subject_type").map_postgres_err()?,
)?,
subject_id: row.try_get("subject_id").map_postgres_err()?,
is_default: row.try_get("is_default").map_postgres_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
updated_at: row.try_get("updated_at").map_postgres_err()?,
})
}
fn map_version_row(row: &PgRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_postgres_err()?,
group_id: row.try_get("group_id").map_postgres_err()?,
version: row.try_get("version").map_postgres_err()?,
config_json: row.try_get("config_json").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
created_by: row.try_get("created_by").map_postgres_err()?,
})
}

View File

@@ -0,0 +1,524 @@
use async_trait::async_trait;
use serde_json::Value;
use sqlx::{sqlite::SqliteRow, Row};
use super::postgres::{
apply_binding_patch, apply_group_patch, binding_subject_from_database,
binding_subject_to_database,
};
use super::*;
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct SqliteRoutingGroupRepository {
pool: SqlitePool,
}
impl SqliteRoutingGroupRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for SqliteRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC"))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect()
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = ? LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = 1 AND enabled = 1 ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE (? IS NULL OR group_id = ?)
AND (? IS NULL OR subject_type = ?)
AND (? IS NULL OR subject_id = ?)
ORDER BY created_at ASC, id ASC
"#
))
.bind(query.group_id.as_deref())
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.bind(query.subject_id.as_deref())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_binding_row).collect()
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let rows = sqlx::query(&format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = ? ORDER BY version DESC, created_at DESC, id ASC"
))
.bind(group_id)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_version_row).collect()
}
}
#[async_trait]
impl RoutingGroupWriteRepository for SqliteRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = ?,
description = ?,
enabled = ?,
is_system_default = ?,
config_json = ?,
version = ?,
updated_at = ?,
published_at = ?
WHERE id = ?
"#,
)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = ?,
subject_type = ?,
subject_id = ?,
is_default = ?,
allow_explicit_select = ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(json_to_string(
&version.config_json,
"routing_group_versions.config_json",
)?)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(version)
}
}
fn map_group_row(row: &SqliteRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_sql_err()?,
name: row.try_get("name").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json",
)?,
version: row.try_get("version").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
published_at: row.try_get("published_at").map_sql_err()?,
})
}
fn map_binding_row(row: &SqliteRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
subject_type: binding_subject_from_database(row.try_get("subject_type").map_sql_err()?)?,
subject_id: row.try_get("subject_id").map_sql_err()?,
is_default: row.try_get("is_default").map_sql_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
})
}
fn map_version_row(row: &SqliteRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
version: row.try_get("version").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_group_versions.config_json",
)?,
created_at: row.try_get("created_at").map_sql_err()?,
created_by: row.try_get("created_by").map_sql_err()?,
})
}
fn json_to_string(value: &Value, field_name: &str) -> Result<String, DataLayerError> {
serde_json::to_string(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains unserializable JSON: {err}"))
})
}
fn json_from_string(value: String, field_name: &str) -> Result<Value, DataLayerError> {
serde_json::from_str(&value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains invalid JSON: {err}"))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::lifecycle::migrate::run_sqlite_migrations;
#[tokio::test]
async fn sqlite_routing_group_repository_round_trips() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_sqlite_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteRoutingGroupRepository::new(pool);
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "routing-group-1".to_string(),
name: "default".to_string(),
description: Some("initial".to_string()),
enabled: true,
is_system_default: true,
config_json: json!({"allowed_models": ["gpt-*"]}),
version: 1,
created_at: 10,
updated_at: 10,
published_at: None,
})
.await
.expect("group should create");
let system_default = repository
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
.await
.expect("group lookup should succeed")
.expect("system default should exist");
assert_eq!(system_default.id, "routing-group-1");
repository
.update_routing_group(
"routing-group-1",
UpdateRoutingGroupRecord {
description: Some(None),
version: Some(2),
updated_at: 20,
published_at: Some(Some(20)),
..UpdateRoutingGroupRecord::default()
},
)
.await
.expect("group should update");
let binding = repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-1".to_string(),
group_id: "routing-group-1".to_string(),
subject_type: RoutingGroupBindingSubject::ApiKey,
subject_id: "api-key-1".to_string(),
is_default: true,
allow_explicit_select: true,
created_at: 10,
updated_at: 10,
})
.await
.expect("binding should create");
assert_eq!(binding.subject_type, RoutingGroupBindingSubject::ApiKey);
assert_eq!(
repository
.list_routing_group_bindings(&RoutingGroupBindingQuery {
group_id: Some("routing-group-1".to_string()),
subject_type: Some(RoutingGroupBindingSubject::ApiKey),
subject_id: Some("api-key-1".to_string()),
})
.await
.expect("bindings should list")
.len(),
1
);
repository
.create_routing_group_version(CreateRoutingGroupVersionRecord {
id: "version-1".to_string(),
group_id: "routing-group-1".to_string(),
version: 2,
config_json: json!({"allowed_models": ["gpt-*"]}),
created_at: 20,
created_by: Some("admin".to_string()),
})
.await
.expect("version should create");
assert_eq!(
repository
.list_routing_group_versions("routing-group-1")
.await
.expect("versions should list")
.len(),
1
);
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "aether-routing-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Pure routing profile policy, mutation, ranking overlay, and trace primitives for Aether"
[dependencies]
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View File

@@ -0,0 +1,91 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingRulePhase {
#[default]
ClientRequest,
ProviderRequest,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingSetPriorityMode {
#[default]
Provider,
GlobalKey,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingSchedulingMode {
#[default]
CacheAffinity,
LoadBalance,
FixedOrder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "op")]
pub enum RoutingJsonPatchOperation {
Add { path: String, value: Value },
Replace { path: String, value: Value },
Remove { path: String },
}
impl RoutingJsonPatchOperation {
pub fn path(&self) -> &str {
match self {
Self::Add { path, .. } | Self::Replace { path, .. } | Self::Remove { path } => path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "op")]
pub enum RoutingHeaderPatch {
Set { name: String, value: String },
Remove { name: String },
}
impl RoutingHeaderPatch {
pub fn name(&self) -> &str {
match self {
Self::Set { name, .. } | Self::Remove { name } => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum RoutingAction {
RestrictModels {
models: Vec<String>,
},
RestrictProviders {
provider_ids: Vec<String>,
},
RestrictKeys {
key_ids: Vec<String>,
},
SetScheduling {
priority_mode: Option<RoutingSetPriorityMode>,
scheduling_mode: Option<RoutingSchedulingMode>,
keep_priority_on_conversion: Option<bool>,
},
SetProviderPriority {
provider_id: String,
priority: i32,
},
SetKeyPriority {
key_id: String,
priority: i32,
},
JsonPatchBody {
patch: Vec<RoutingJsonPatchOperation>,
},
PatchHeaders {
patch: Vec<RoutingHeaderPatch>,
},
}

View File

@@ -0,0 +1,232 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingConditionOp {
Eq,
Ne,
In,
Contains,
Exists,
Prefix,
Suffix,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RoutingCondition {
All {
all: Vec<RoutingCondition>,
},
Any {
any: Vec<RoutingCondition>,
},
Not {
not: Box<RoutingCondition>,
},
Predicate {
field: String,
op: RoutingConditionOp,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<Value>,
},
Empty {},
}
impl Default for RoutingCondition {
fn default() -> Self {
Self::Empty {}
}
}
#[derive(Debug, Clone)]
pub struct RoutingConditionContext<'a> {
pub model: &'a str,
pub api_format: &'a str,
pub user_id: Option<&'a str>,
pub api_key_id: Option<&'a str>,
pub headers: &'a Value,
pub body: &'a Value,
}
impl RoutingCondition {
pub fn matches(&self, context: &RoutingConditionContext<'_>) -> bool {
match self {
Self::All { all } => all.iter().all(|condition| condition.matches(context)),
Self::Any { any } => any.iter().any(|condition| condition.matches(context)),
Self::Not { not } => !not.matches(context),
Self::Predicate { field, op, value } => {
let actual = resolve_field(context, field);
compare_condition(actual, *op, value.as_ref())
}
Self::Empty {} => true,
}
}
}
fn resolve_field(context: &RoutingConditionContext<'_>, field: &str) -> Option<Value> {
let normalized = field.trim();
match normalized {
"model" => return Some(Value::String(context.model.to_string())),
"api_format" | "client_api_format" => {
return Some(Value::String(context.api_format.to_string()))
}
"user_id" => {
return context
.user_id
.map(|value| Value::String(value.to_string()))
}
"api_key_id" => {
return context
.api_key_id
.map(|value| Value::String(value.to_string()))
}
_ => {}
}
if let Some(path) = normalized.strip_prefix("headers.") {
return lookup_dotted_path(context.headers, path).cloned();
}
if let Some(path) = normalized.strip_prefix("body.") {
return lookup_dotted_path(context.body, path).cloned();
}
None
}
fn lookup_dotted_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = root;
for part in path.split('.').filter(|part| !part.is_empty()) {
match current {
Value::Object(map) => current = map.get(part)?,
Value::Array(items) => {
let index = part.parse::<usize>().ok()?;
current = items.get(index)?;
}
_ => return None,
}
}
Some(current)
}
fn compare_condition(
actual: Option<Value>,
op: RoutingConditionOp,
expected: Option<&Value>,
) -> bool {
match op {
RoutingConditionOp::Exists => actual.is_some(),
RoutingConditionOp::Eq => actual
.as_ref()
.zip(expected)
.is_some_and(|(actual, expected)| values_equal(actual, expected)),
RoutingConditionOp::Ne => actual
.as_ref()
.zip(expected)
.is_none_or(|(actual, expected)| !values_equal(actual, expected)),
RoutingConditionOp::In => {
actual
.as_ref()
.zip(expected)
.is_some_and(|(actual, expected)| {
expected
.as_array()
.is_some_and(|items| items.iter().any(|item| values_equal(actual, item)))
})
}
RoutingConditionOp::Contains => {
actual
.as_ref()
.zip(expected)
.is_some_and(|(actual, expected)| {
let Some(expected) = expected.as_str() else {
return false;
};
value_as_string(actual).is_some_and(|actual| actual.contains(expected))
})
}
RoutingConditionOp::Prefix => {
actual
.as_ref()
.zip(expected)
.is_some_and(|(actual, expected)| {
let Some(expected) = expected.as_str() else {
return false;
};
value_as_string(actual).is_some_and(|actual| actual.starts_with(expected))
})
}
RoutingConditionOp::Suffix => {
actual
.as_ref()
.zip(expected)
.is_some_and(|(actual, expected)| {
let Some(expected) = expected.as_str() else {
return false;
};
value_as_string(actual).is_some_and(|actual| actual.ends_with(expected))
})
}
}
}
fn values_equal(left: &Value, right: &Value) -> bool {
match (value_as_string(left), value_as_string(right)) {
(Some(left), Some(right)) => left == right,
_ => left == right,
}
}
fn value_as_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn context<'a>(headers: &'a Value, body: &'a Value) -> RoutingConditionContext<'a> {
RoutingConditionContext {
model: "gpt-5",
api_format: "openai:chat",
user_id: Some("user-1"),
api_key_id: Some("key-1"),
headers,
body,
}
}
#[test]
fn matches_all_body_and_header_predicates() {
let condition = RoutingCondition::All {
all: vec![
RoutingCondition::Predicate {
field: "model".to_string(),
op: RoutingConditionOp::Eq,
value: Some(json!("gpt-5")),
},
RoutingCondition::Predicate {
field: "headers.x-app".to_string(),
op: RoutingConditionOp::Eq,
value: Some(json!("coding")),
},
RoutingCondition::Predicate {
field: "body.reasoning_effort".to_string(),
op: RoutingConditionOp::In,
value: Some(json!(["high", "xhigh"])),
},
],
};
let headers = json!({"x-app":"coding"});
assert!(condition.matches(&context(&headers, &json!({"reasoning_effort":"high"}))));
assert!(!condition.matches(&context(&headers, &json!({"reasoning_effort":"low"}))));
}
}

View File

@@ -0,0 +1,36 @@
mod actions;
mod conditions;
mod model;
mod mutations;
mod policy;
mod ranking;
mod trace;
mod validation;
pub use actions::{
RoutingAction, RoutingHeaderPatch, RoutingJsonPatchOperation, RoutingRulePhase,
RoutingSchedulingMode, RoutingSetPriorityMode,
};
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
pub use model::{
RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig, RoutingGroupRecord,
RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride, RoutingRule,
RoutingSchedulingPreset,
};
pub use mutations::{
apply_json_patch_operations, validate_header_patch, validate_json_patch_operations,
HeaderMutation, MutationError, MutationPlan,
};
pub use policy::{
resolve_routing_policy, MatchedRoutingRule, ResolvedRoutingPolicy, RoutingPolicyError,
RoutingPolicyInput,
};
pub use ranking::{
rank_vector_for_candidate, CandidateKind, RankingOverlay, RoutingCandidateFacts,
RoutingCandidateRankVector, ROUTING_PRIORITY_UNSPECIFIED,
};
pub use trace::{
RoutingCandidateTrace, RoutingDecisionTrace, RoutingPatchSummary, RoutingPoolExpansionTrace,
RoutingRuntimeFacts,
};
pub use validation::{validate_routing_group_config, RoutingValidationError};

View File

@@ -0,0 +1,131 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::actions::{
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
};
use crate::conditions::RoutingCondition;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingSchedulingPreset {
pub preset: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingPoolPolicyOverride {
#[serde(default)]
pub scheduling_presets: Vec<RoutingSchedulingPreset>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingDefaultPolicy {
#[serde(default)]
pub priority_mode: RoutingSetPriorityMode,
#[serde(default)]
pub scheduling_mode: RoutingSchedulingMode,
#[serde(default)]
pub keep_priority_on_conversion: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingModelPolicy {
pub model: String,
#[serde(default)]
pub allowed_providers: Vec<String>,
#[serde(default)]
pub allowed_keys: Vec<String>,
#[serde(default)]
pub provider_priority_overrides: BTreeMap<String, i32>,
#[serde(default)]
pub key_priority_overrides: BTreeMap<String, i32>,
#[serde(default)]
pub pool_priority_overrides: BTreeMap<String, i32>,
#[serde(default)]
pub pool_policy_overrides: BTreeMap<String, RoutingPoolPolicyOverride>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingRule {
pub id: String,
#[serde(default)]
pub priority: i32,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub phase: RoutingRulePhase,
#[serde(default)]
pub conditions: RoutingCondition,
#[serde(default)]
pub actions: Vec<RoutingAction>,
#[serde(default)]
pub stop_processing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingGroupConfig {
#[serde(default)]
pub allowed_models: Vec<String>,
#[serde(default)]
pub default_policy: RoutingDefaultPolicy,
#[serde(default)]
pub model_policies: Vec<RoutingModelPolicy>,
#[serde(default)]
pub rules: Vec<RoutingRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingGroupRecord {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub enabled: bool,
pub is_system_default: bool,
pub config_json: Value,
pub version: i64,
pub created_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingGroupBindingSubject {
User,
ApiKey,
UserGroup,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingGroupBinding {
pub id: String,
pub group_id: String,
pub subject_type: RoutingGroupBindingSubject,
pub subject_id: String,
pub is_default: bool,
pub allow_explicit_select: bool,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingGroupVersionRecord {
pub id: String,
pub group_id: String,
pub version: i64,
pub config_json: Value,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
fn default_true() -> bool {
true
}

View File

@@ -0,0 +1,236 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
use crate::actions::{RoutingHeaderPatch, RoutingJsonPatchOperation};
const RESERVED_HEADERS: &[&str] = &[
"authorization",
"x-api-key",
"api-key",
"cookie",
"set-cookie",
"x-aether-trace-id",
"x-aether-internal",
"x-aether-scheduler-group",
];
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum MutationError {
#[error("json patch path must be an absolute JSON pointer: {0}")]
InvalidJsonPointer(String),
#[error("json patch cannot target reserved path: {0}")]
ReservedJsonPath(String),
#[error("json patch target does not exist: {0}")]
MissingTarget(String),
#[error("json patch parent is not an object: {0}")]
InvalidParent(String),
#[error("header patch targets reserved header: {0}")]
ReservedHeader(String),
#[error("header patch has invalid header name: {0}")]
InvalidHeaderName(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeaderMutation {
pub set: Vec<(String, String)>,
pub remove: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MutationPlan {
pub body_patch: Vec<RoutingJsonPatchOperation>,
pub header_patch: Vec<RoutingHeaderPatch>,
}
impl MutationPlan {
pub fn is_empty(&self) -> bool {
self.body_patch.is_empty() && self.header_patch.is_empty()
}
}
pub fn validate_json_patch_operations(
operations: &[RoutingJsonPatchOperation],
) -> Result<(), MutationError> {
for operation in operations {
let path = operation.path();
validate_json_pointer(path)?;
if is_reserved_json_path(path) {
return Err(MutationError::ReservedJsonPath(path.to_string()));
}
}
Ok(())
}
pub fn apply_json_patch_operations(
body: &mut Value,
operations: &[RoutingJsonPatchOperation],
) -> Result<(), MutationError> {
validate_json_patch_operations(operations)?;
for operation in operations {
match operation {
RoutingJsonPatchOperation::Add { path, value } => {
set_json_pointer(body, path, value.clone(), true)?;
}
RoutingJsonPatchOperation::Replace { path, value } => {
set_json_pointer(body, path, value.clone(), false)?;
}
RoutingJsonPatchOperation::Remove { path } => {
remove_json_pointer(body, path)?;
}
}
}
Ok(())
}
pub fn validate_header_patch(patch: &[RoutingHeaderPatch]) -> Result<(), MutationError> {
let reserved = RESERVED_HEADERS.iter().copied().collect::<BTreeSet<_>>();
for item in patch {
let name = item.name().trim().to_ascii_lowercase();
if name.is_empty()
|| name
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-'))
{
return Err(MutationError::InvalidHeaderName(item.name().to_string()));
}
if reserved.contains(name.as_str()) {
return Err(MutationError::ReservedHeader(item.name().to_string()));
}
}
Ok(())
}
fn validate_json_pointer(path: &str) -> Result<(), MutationError> {
if !path.starts_with('/') {
return Err(MutationError::InvalidJsonPointer(path.to_string()));
}
Ok(())
}
fn is_reserved_json_path(path: &str) -> bool {
matches!(
path,
"/authorization"
| "/api_key"
| "/provider_secret"
| "/upstream_url"
| "/upstream_base_url"
| "/auth"
)
}
fn set_json_pointer(
root: &mut Value,
pointer: &str,
value: Value,
allow_create: bool,
) -> Result<(), MutationError> {
let tokens = pointer_tokens(pointer);
if tokens.is_empty() {
*root = value;
return Ok(());
}
let (parents, leaf) = tokens.split_at(tokens.len() - 1);
let parent = descend_mut(root, parents, pointer)?;
match parent {
Value::Object(map) => {
if !allow_create && !map.contains_key(&leaf[0]) {
return Err(MutationError::MissingTarget(pointer.to_string()));
}
map.insert(leaf[0].clone(), value);
Ok(())
}
_ => Err(MutationError::InvalidParent(pointer.to_string())),
}
}
fn remove_json_pointer(root: &mut Value, pointer: &str) -> Result<(), MutationError> {
let tokens = pointer_tokens(pointer);
if tokens.is_empty() {
*root = Value::Null;
return Ok(());
}
let (parents, leaf) = tokens.split_at(tokens.len() - 1);
let parent = descend_mut(root, parents, pointer)?;
match parent {
Value::Object(map) => map
.remove(&leaf[0])
.map(|_| ())
.ok_or_else(|| MutationError::MissingTarget(pointer.to_string())),
_ => Err(MutationError::InvalidParent(pointer.to_string())),
}
}
fn descend_mut<'a>(
root: &'a mut Value,
tokens: &[String],
pointer: &str,
) -> Result<&'a mut Value, MutationError> {
let mut current = root;
for token in tokens {
match current {
Value::Object(map) => {
current = map
.get_mut(token)
.ok_or_else(|| MutationError::MissingTarget(pointer.to_string()))?;
}
Value::Null => {
*current = Value::Object(Map::new());
if let Value::Object(map) = current {
current = map
.entry(token.clone())
.or_insert_with(|| Value::Object(Map::new()));
}
}
_ => return Err(MutationError::InvalidParent(pointer.to_string())),
}
}
Ok(current)
}
fn pointer_tokens(pointer: &str) -> Vec<String> {
pointer
.trim_start_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.map(|part| part.replace("~1", "/").replace("~0", "~"))
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::actions::RoutingJsonPatchOperation;
use super::*;
#[test]
fn applies_body_patch() {
let mut body = json!({"metadata":{}});
apply_json_patch_operations(
&mut body,
&[RoutingJsonPatchOperation::Add {
path: "/metadata/routing".to_string(),
value: json!("high"),
}],
)
.expect("patch should apply");
assert_eq!(body["metadata"]["routing"], json!("high"));
}
#[test]
fn rejects_reserved_headers() {
assert_eq!(
validate_header_patch(&[RoutingHeaderPatch::Set {
name: "authorization".to_string(),
value: "secret".to_string()
}]),
Err(MutationError::ReservedHeader("authorization".to_string()))
);
}
}

View File

@@ -0,0 +1,423 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::actions::{
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
};
use crate::conditions::RoutingConditionContext;
use crate::model::{RoutingGroupConfig, RoutingModelPolicy, RoutingPoolPolicyOverride};
use crate::mutations::{validate_header_patch, validate_json_patch_operations, MutationPlan};
use crate::ranking::RankingOverlay;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum RoutingPolicyError {
#[error("routing group config is invalid: {0}")]
InvalidConfig(String),
#[error("model is not allowed by routing group: {0}")]
ModelNotAllowed(String),
#[error("mutation action is invalid: {0}")]
InvalidMutation(String),
}
#[derive(Debug, Clone)]
pub struct RoutingPolicyInput<'a> {
pub group_id: Option<&'a str>,
pub group_version: Option<i64>,
pub selection_source: &'a str,
pub requested_model: &'a str,
pub resolved_model: &'a str,
pub api_format: &'a str,
pub user_id: Option<&'a str>,
pub api_key_id: Option<&'a str>,
pub headers: &'a Value,
pub body: &'a Value,
pub phase: RoutingRulePhase,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MatchedRoutingRule {
pub id: String,
pub priority: i32,
pub phase: RoutingRulePhase,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedRoutingPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_version: Option<i64>,
pub selection_source: String,
pub requested_model: String,
pub resolved_model: String,
pub priority_mode: RoutingSetPriorityMode,
pub scheduling_mode: RoutingSchedulingMode,
pub keep_priority_on_conversion: bool,
pub ranking_overlay: RankingOverlay,
pub mutation_plan: MutationPlan,
#[serde(default)]
pub pool_policy_overrides: BTreeMap<String, RoutingPoolPolicyOverride>,
#[serde(default)]
pub matched_rules: Vec<MatchedRoutingRule>,
}
pub fn resolve_routing_policy(
config: &RoutingGroupConfig,
input: RoutingPolicyInput<'_>,
) -> Result<ResolvedRoutingPolicy, RoutingPolicyError> {
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 {
group_id: input.group_id.map(str::to_string),
group_version: input.group_version,
selection_source: input.selection_source.to_string(),
requested_model: input.requested_model.to_string(),
resolved_model: input.resolved_model.to_string(),
priority_mode: config.default_policy.priority_mode,
scheduling_mode: config.default_policy.scheduling_mode,
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
ranking_overlay: RankingOverlay::default(),
mutation_plan: MutationPlan::default(),
pool_policy_overrides: BTreeMap::new(),
matched_rules: Vec::new(),
};
for model_policy in matching_model_policies(config, input.requested_model, input.resolved_model)
{
apply_model_policy(&mut policy, model_policy);
}
let condition_context = RoutingConditionContext {
model: input.requested_model,
api_format: input.api_format,
user_id: input.user_id,
api_key_id: input.api_key_id,
headers: input.headers,
body: input.body,
};
let mut rules = config
.rules
.iter()
.filter(|rule| rule.enabled && rule.phase == input.phase)
.collect::<Vec<_>>();
rules.sort_by(|left, right| {
left.priority
.cmp(&right.priority)
.then(left.id.cmp(&right.id))
});
for rule in rules {
if !rule.conditions.matches(&condition_context) {
continue;
}
for action in &rule.actions {
apply_action(
&mut policy,
action,
input.requested_model,
input.resolved_model,
)?;
}
policy.matched_rules.push(MatchedRoutingRule {
id: rule.id.clone(),
priority: rule.priority,
phase: rule.phase,
});
if rule.stop_processing {
break;
}
}
Ok(policy)
}
fn apply_model_policy(policy: &mut ResolvedRoutingPolicy, model_policy: &RoutingModelPolicy) {
if !model_policy.allowed_providers.is_empty() {
policy.ranking_overlay.allowed_providers = model_policy.allowed_providers.clone();
}
if !model_policy.allowed_keys.is_empty() {
policy.ranking_overlay.allowed_keys = model_policy.allowed_keys.clone();
}
policy.ranking_overlay.provider_priority_overrides.extend(
model_policy
.provider_priority_overrides
.iter()
.map(|(key, value)| (key.clone(), *value)),
);
policy.ranking_overlay.key_priority_overrides.extend(
model_policy
.key_priority_overrides
.iter()
.map(|(key, value)| (key.clone(), *value)),
);
policy.ranking_overlay.pool_priority_overrides.extend(
model_policy
.pool_priority_overrides
.iter()
.map(|(key, value)| (key.clone(), *value)),
);
policy
.pool_policy_overrides
.extend(model_policy.pool_policy_overrides.clone());
}
fn apply_action(
policy: &mut ResolvedRoutingPolicy,
action: &RoutingAction,
requested_model: &str,
resolved_model: &str,
) -> Result<(), RoutingPolicyError> {
match action {
RoutingAction::RestrictModels { models } => {
if !model_allowed(models, requested_model) && !model_allowed(models, resolved_model) {
return Err(RoutingPolicyError::ModelNotAllowed(
requested_model.to_string(),
));
}
}
RoutingAction::RestrictProviders { provider_ids } => {
policy.ranking_overlay.allowed_providers = provider_ids.clone();
}
RoutingAction::RestrictKeys { key_ids } => {
policy.ranking_overlay.allowed_keys = key_ids.clone();
}
RoutingAction::SetScheduling {
priority_mode,
scheduling_mode,
keep_priority_on_conversion,
} => {
if let Some(priority_mode) = priority_mode {
policy.priority_mode = *priority_mode;
}
if let Some(scheduling_mode) = scheduling_mode {
policy.scheduling_mode = *scheduling_mode;
}
if let Some(keep_priority_on_conversion) = keep_priority_on_conversion {
policy.keep_priority_on_conversion = *keep_priority_on_conversion;
}
}
RoutingAction::SetProviderPriority {
provider_id,
priority,
} => {
policy
.ranking_overlay
.provider_priority_overrides
.insert(provider_id.clone(), *priority);
}
RoutingAction::SetKeyPriority { key_id, priority } => {
policy
.ranking_overlay
.key_priority_overrides
.insert(key_id.clone(), *priority);
}
RoutingAction::JsonPatchBody { patch } => {
validate_json_patch_operations(patch)
.map_err(|error| RoutingPolicyError::InvalidMutation(error.to_string()))?;
policy.mutation_plan.body_patch.extend(patch.clone());
}
RoutingAction::PatchHeaders { patch } => {
validate_header_patch(patch)
.map_err(|error| RoutingPolicyError::InvalidMutation(error.to_string()))?;
policy.mutation_plan.header_patch.extend(patch.clone());
}
}
Ok(())
}
fn matching_model_policies<'a>(
config: &'a RoutingGroupConfig,
requested_model: &str,
resolved_model: &str,
) -> Vec<&'a RoutingModelPolicy> {
config
.model_policies
.iter()
.filter(|policy| {
model_pattern_matches(&policy.model, requested_model)
|| model_pattern_matches(&policy.model, resolved_model)
})
.collect()
}
fn model_allowed(patterns: &[String], requested_model: &str) -> bool {
patterns.is_empty()
|| patterns
.iter()
.any(|pattern| model_pattern_matches(pattern, requested_model))
}
fn model_pattern_matches(pattern: &str, value: &str) -> bool {
let pattern = pattern.trim();
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
pattern == value
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use crate::actions::{RoutingJsonPatchOperation, RoutingRulePhase};
use crate::conditions::{RoutingCondition, RoutingConditionOp};
use crate::model::{RoutingDefaultPolicy, RoutingRule};
use super::*;
#[test]
fn resolves_model_policy_and_matching_rule() {
let config = RoutingGroupConfig {
allowed_models: vec!["gpt-*".to_string()],
default_policy: RoutingDefaultPolicy::default(),
model_policies: vec![RoutingModelPolicy {
model: "gpt-5".to_string(),
allowed_providers: vec!["provider-a".to_string()],
provider_priority_overrides: BTreeMap::from([("provider-a".to_string(), 0)]),
pool_priority_overrides: BTreeMap::from([("provider-a".to_string(), 3)]),
..RoutingModelPolicy::default()
}],
rules: vec![RoutingRule {
id: "high".to_string(),
priority: 10,
enabled: true,
phase: RoutingRulePhase::ClientRequest,
conditions: RoutingCondition::Predicate {
field: "body.reasoning_effort".to_string(),
op: RoutingConditionOp::Eq,
value: Some(json!("high")),
},
actions: vec![RoutingAction::JsonPatchBody {
patch: vec![RoutingJsonPatchOperation::Add {
path: "/metadata/routing".to_string(),
value: json!("high"),
}],
}],
stop_processing: false,
}],
};
let policy = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "explicit",
requested_model: "gpt-5",
resolved_model: "gpt-5",
api_format: "openai:chat",
user_id: Some("user-1"),
api_key_id: Some("api-key-1"),
headers: &json!({}),
body: &json!({"reasoning_effort":"high"}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("policy should resolve");
assert_eq!(policy.ranking_overlay.allowed_providers, vec!["provider-a"]);
assert_eq!(
policy
.ranking_overlay
.provider_priority_overrides
.get("provider-a"),
Some(&0)
);
assert_eq!(
policy
.ranking_overlay
.pool_priority_overrides
.get("provider-a"),
Some(&3)
);
assert_eq!(policy.matched_rules.len(), 1);
assert_eq!(policy.mutation_plan.body_patch.len(), 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]
fn restrict_model_action_rejects_matching_request() {
let config = RoutingGroupConfig {
allowed_models: vec!["*".to_string()],
rules: vec![RoutingRule {
id: "restrict".to_string(),
priority: 1,
enabled: true,
phase: RoutingRulePhase::ClientRequest,
conditions: RoutingCondition::default(),
actions: vec![RoutingAction::RestrictModels {
models: vec!["gpt-5".to_string()],
}],
stop_processing: false,
}],
..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())
);
}
}

View File

@@ -0,0 +1,181 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ROUTING_PRIORITY_UNSPECIFIED: i32 = i32::MAX;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateKind {
Provider,
PoolGroup,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RankingOverlay {
#[serde(default)]
pub allowed_providers: Vec<String>,
#[serde(default)]
pub allowed_keys: Vec<String>,
#[serde(default)]
pub provider_priority_overrides: BTreeMap<String, i32>,
#[serde(default)]
pub key_priority_overrides: BTreeMap<String, i32>,
#[serde(default)]
pub pool_priority_overrides: BTreeMap<String, i32>,
}
impl RankingOverlay {
pub fn provider_priority(&self, provider_id: &str, fallback: i32) -> i32 {
self.provider_priority_overrides
.get(provider_id)
.copied()
.unwrap_or(fallback)
}
pub fn key_priority(&self, key_id: &str, fallback: i32) -> i32 {
self.key_priority_overrides
.get(key_id)
.copied()
.unwrap_or(fallback)
}
pub fn provider_priority_or_unspecified(&self, provider_id: &str) -> i32 {
self.provider_priority_overrides
.get(provider_id)
.copied()
.unwrap_or(ROUTING_PRIORITY_UNSPECIFIED)
}
pub fn key_priority_or_unspecified(&self, key_id: &str) -> i32 {
self.key_priority_overrides
.get(key_id)
.copied()
.unwrap_or(ROUTING_PRIORITY_UNSPECIFIED)
}
pub fn pool_priority_or_unspecified(&self, provider_id: &str) -> i32 {
self.pool_priority_overrides
.get(provider_id)
.copied()
.unwrap_or(ROUTING_PRIORITY_UNSPECIFIED)
}
pub fn provider_allowed(&self, provider_id: &str) -> bool {
self.allowed_providers.is_empty()
|| self
.allowed_providers
.iter()
.any(|item| item == provider_id)
}
pub fn key_allowed(&self, key_id: &str) -> bool {
self.allowed_keys.is_empty() || self.allowed_keys.iter().any(|item| item == key_id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingCandidateFacts {
pub candidate_kind: CandidateKind,
pub provider_id: String,
pub endpoint_id: String,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
pub provider_priority: i32,
pub key_priority: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingCandidateRankVector {
pub provider_priority_before: i32,
pub provider_priority_after: i32,
pub key_priority_before: i32,
pub key_priority_after: i32,
}
pub fn rank_vector_for_candidate(
overlay: &RankingOverlay,
facts: &RoutingCandidateFacts,
) -> RoutingCandidateRankVector {
RoutingCandidateRankVector {
provider_priority_before: facts.provider_priority,
provider_priority_after: overlay.provider_priority_or_unspecified(&facts.provider_id),
key_priority_before: facts.key_priority,
key_priority_after: match facts.candidate_kind {
CandidateKind::Provider => facts
.key_id
.as_deref()
.map(|key_id| overlay.key_priority_or_unspecified(key_id))
.unwrap_or(ROUTING_PRIORITY_UNSPECIFIED),
CandidateKind::PoolGroup => overlay.pool_priority_or_unspecified(&facts.provider_id),
},
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn overlay_applies_provider_and_key_priority() {
let overlay = RankingOverlay {
provider_priority_overrides: BTreeMap::from([("provider-a".to_string(), 2)]),
key_priority_overrides: BTreeMap::from([("key-a".to_string(), 5)]),
..RankingOverlay::default()
};
let facts = RoutingCandidateFacts {
candidate_kind: CandidateKind::Provider,
provider_id: "provider-a".to_string(),
endpoint_id: "endpoint-a".to_string(),
model_id: "model-a".to_string(),
key_id: Some("key-a".to_string()),
provider_priority: 10,
key_priority: 20,
};
let vector = rank_vector_for_candidate(&overlay, &facts);
assert_eq!(vector.provider_priority_after, 2);
assert_eq!(vector.key_priority_after, 5);
}
#[test]
fn rank_vector_marks_missing_routing_priorities_unspecified() {
let facts = RoutingCandidateFacts {
candidate_kind: CandidateKind::Provider,
provider_id: "provider-a".to_string(),
endpoint_id: "endpoint-a".to_string(),
model_id: "model-a".to_string(),
key_id: Some("key-a".to_string()),
provider_priority: 10,
key_priority: 20,
};
let vector = rank_vector_for_candidate(&RankingOverlay::default(), &facts);
assert_eq!(vector.provider_priority_after, ROUTING_PRIORITY_UNSPECIFIED);
assert_eq!(vector.key_priority_after, ROUTING_PRIORITY_UNSPECIFIED);
}
#[test]
fn rank_vector_uses_pool_priority_for_pool_groups() {
let overlay = RankingOverlay {
pool_priority_overrides: BTreeMap::from([("provider-a".to_string(), 4)]),
..RankingOverlay::default()
};
let facts = RoutingCandidateFacts {
candidate_kind: CandidateKind::PoolGroup,
provider_id: "provider-a".to_string(),
endpoint_id: "endpoint-a".to_string(),
model_id: "model-a".to_string(),
key_id: None,
provider_priority: 10,
key_priority: 20,
};
let vector = rank_vector_for_candidate(&overlay, &facts);
assert_eq!(vector.key_priority_after, 4);
}
}

View File

@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::actions::{RoutingSchedulingMode, RoutingSetPriorityMode};
use crate::ranking::{CandidateKind, RoutingCandidateRankVector};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingPatchSummary {
#[serde(default)]
pub body_paths: Vec<String>,
#[serde(default)]
pub header_names: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failed_action: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingCandidateTrace {
pub candidate_kind: CandidateKind,
pub provider_id: String,
pub endpoint_id: String,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
pub ranking_vector: RoutingCandidateRankVector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_order: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingPoolExpansionTrace {
pub pool_group_id: String,
pub key_id: String,
#[serde(default)]
pub pool_ranking_vector: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool_skip_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_order: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingRuntimeFacts {
#[serde(default)]
pub cache_affinity_hit: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sticky_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_balance_seed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduler_mode: Option<RoutingSchedulingMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority_mode: Option<RoutingSetPriorityMode>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingDecisionTrace {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_version: Option<i64>,
pub selection_source: String,
#[serde(default)]
pub selected_rules: Vec<String>,
pub original_model: String,
pub resolved_model: String,
pub client_api_format: String,
#[serde(default)]
pub client_request_patch_summary: RoutingPatchSummary,
#[serde(default)]
pub provider_request_patch_summary: RoutingPatchSummary,
#[serde(default)]
pub global_candidates: Vec<RoutingCandidateTrace>,
#[serde(default)]
pub pool_expansion: Vec<RoutingPoolExpansionTrace>,
#[serde(default)]
pub runtime_facts: RoutingRuntimeFacts,
}
impl RoutingDecisionTrace {
pub fn to_extra_data_value(&self) -> Value {
serde_json::json!({ "routing_trace": self })
}
}

View File

@@ -0,0 +1,54 @@
use std::collections::BTreeSet;
use thiserror::Error;
use crate::model::RoutingGroupConfig;
use crate::mutations::{validate_header_patch, validate_json_patch_operations};
use crate::RoutingAction;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum RoutingValidationError {
#[error("routing rule id is empty")]
EmptyRuleId,
#[error("duplicate routing rule id: {0}")]
DuplicateRuleId(String),
#[error("routing model policy selector is empty")]
EmptyModelSelector,
#[error("invalid mutation action: {0}")]
InvalidMutation(String),
}
pub fn validate_routing_group_config(
config: &RoutingGroupConfig,
) -> Result<(), RoutingValidationError> {
let mut rule_ids = BTreeSet::new();
for model_policy in &config.model_policies {
if model_policy.model.trim().is_empty() {
return Err(RoutingValidationError::EmptyModelSelector);
}
}
for rule in &config.rules {
if rule.id.trim().is_empty() {
return Err(RoutingValidationError::EmptyRuleId);
}
if !rule_ids.insert(rule.id.clone()) {
return Err(RoutingValidationError::DuplicateRuleId(rule.id.clone()));
}
for action in &rule.actions {
match action {
RoutingAction::JsonPatchBody { patch } => {
validate_json_patch_operations(patch).map_err(|error| {
RoutingValidationError::InvalidMutation(error.to_string())
})?;
}
RoutingAction::PatchHeaders { patch } => {
validate_header_patch(patch).map_err(|error| {
RoutingValidationError::InvalidMutation(error.to_string())
})?;
}
_ => {}
}
}
}
Ok(())
}

View File

@@ -36,6 +36,7 @@ pub struct SchedulerRequestCandidateReportContext {
pub priority_slot: Option<i32>,
pub promoted_by: Option<String>,
pub demoted_by: Option<String>,
pub routing_trace: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -83,6 +84,7 @@ struct ReportCandidateExtraDataInput {
priority_slot: Option<i32>,
promoted_by: Option<String>,
demoted_by: Option<String>,
routing_trace: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -180,6 +182,10 @@ pub fn parse_request_candidate_report_context(
priority_slot: i32_field(report_context, "priority_slot"),
promoted_by: string_field(report_context, "promoted_by"),
demoted_by: string_field(report_context, "demoted_by"),
routing_trace: report_context
.get("routing_trace")
.cloned()
.filter(|value| !value.is_null()),
})
}
@@ -221,6 +227,7 @@ pub fn resolve_report_request_candidate_slot(
priority_slot,
promoted_by,
demoted_by,
routing_trace,
} = metadata;
let request_id = request_id?;
let synthesized_extra_data = build_report_candidate_extra_data(ReportCandidateExtraDataInput {
@@ -245,6 +252,7 @@ pub fn resolve_report_request_candidate_slot(
priority_slot,
promoted_by,
demoted_by,
routing_trace,
});
let created_at_unix_ms = matched_candidate
.as_ref()
@@ -368,6 +376,7 @@ pub fn build_execution_request_candidate_seed(
priority_slot: metadata.priority_slot,
promoted_by: metadata.promoted_by,
demoted_by: metadata.demoted_by,
routing_trace: metadata.routing_trace,
})
});
append_seed_extra_data_from_report_context(&mut extra_data, &context);
@@ -477,6 +486,7 @@ pub fn build_local_request_candidate_status_record(
priority_slot: metadata.priority_slot,
promoted_by: metadata.promoted_by.clone(),
demoted_by: metadata.demoted_by.clone(),
routing_trace: metadata.routing_trace.clone(),
});
let created_at_unix_ms = started_at_unix_ms.or(finished_at_unix_ms);
@@ -717,6 +727,7 @@ fn build_report_candidate_extra_data(input: ReportCandidateExtraDataInput) -> Op
priority_slot,
promoted_by,
demoted_by,
routing_trace,
} = input;
let mut extra_data = Map::with_capacity(8);
extra_data.insert("gateway_execution_runtime".to_string(), Value::Bool(true));
@@ -812,6 +823,9 @@ fn build_report_candidate_extra_data(input: ReportCandidateExtraDataInput) -> Op
if let Some(demoted_by) = demoted_by {
extra_data.insert("demoted_by".to_string(), Value::String(demoted_by));
}
if let Some(routing_trace) = routing_trace {
extra_data.insert("routing_trace".to_string(), routing_trace);
}
(!extra_data.is_empty()).then_some(Value::Object(extra_data))
}
@@ -1138,7 +1152,15 @@ mod tests {
"ranking_index": 2,
"priority_slot": 7,
"promoted_by": "cached_affinity",
"demoted_by": "cross_format"
"demoted_by": "cross_format",
"routing_trace": {
"group_id": "routing-group-1",
"pool_expansion": [{
"pool_group_id": "pool-1",
"key_id": "key-1",
"selected_order": 0
}]
}
})),
status_update: SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
@@ -1228,6 +1250,14 @@ mod tests {
.and_then(|value| value.get("demoted_by")),
Some(&json!("cross_format"))
);
assert_eq!(
record
.extra_data
.as_ref()
.and_then(|value| value.get("routing_trace"))
.and_then(|value| value.get("group_id")),
Some(&json!("routing-group-1"))
);
}
#[test]