Merge upstream/main

This commit is contained in:
ZheFox
2026-05-18 13:11:11 +08:00
124 changed files with 11698 additions and 581 deletions

View File

@@ -6,6 +6,7 @@ pub(super) mod features;
mod model;
pub(super) mod observability;
pub(super) mod provider;
mod routing;
mod system;
mod users;

View File

@@ -64,6 +64,14 @@ impl<'a> AdminAppState<'a> {
self.app.has_global_model_data_writer()
}
pub(crate) fn has_routing_group_data_reader(&self) -> bool {
self.app.has_routing_group_data_reader()
}
pub(crate) fn has_routing_group_data_writer(&self) -> bool {
self.app.has_routing_group_data_writer()
}
pub(crate) fn has_usage_data_reader(&self) -> bool {
self.app.has_usage_data_reader()
}

View File

@@ -9,6 +9,7 @@ mod observability;
mod provider;
mod provider_oauth;
mod route_request;
mod routing_profiles;
mod state;
mod system;
mod users;

View File

@@ -0,0 +1,85 @@
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupLookupKey, StoredRoutingGroup, StoredRoutingGroupBinding,
StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
};
use super::AdminAppState;
use crate::GatewayError;
impl<'a> AdminAppState<'a> {
pub(crate) async fn list_routing_groups(
&self,
) -> Result<Vec<StoredRoutingGroup>, GatewayError> {
self.app.list_routing_groups().await
}
pub(crate) async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, GatewayError> {
self.app.find_routing_group(lookup).await
}
pub(crate) async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, GatewayError> {
self.app.list_routing_group_bindings(query).await
}
pub(crate) async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, GatewayError> {
self.app.list_routing_group_versions(group_id).await
}
pub(crate) async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, GatewayError> {
self.app.create_routing_group(record).await
}
pub(crate) async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, GatewayError> {
self.app.update_routing_group(id, patch).await
}
pub(crate) async fn delete_routing_group(&self, id: &str) -> Result<bool, GatewayError> {
self.app.delete_routing_group(id).await
}
pub(crate) async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, GatewayError> {
self.app.create_routing_group_binding(record).await
}
pub(crate) async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, GatewayError> {
self.app.update_routing_group_binding(id, patch).await
}
pub(crate) async fn delete_routing_group_binding(
&self,
id: &str,
) -> Result<bool, GatewayError> {
self.app.delete_routing_group_binding(id).await
}
pub(crate) async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<Option<StoredRoutingGroupVersion>, GatewayError> {
self.app.create_routing_group_version(record).await
}
}

View File

@@ -1,6 +1,6 @@
use super::{
announcements, auth, billing, endpoint, features, model, observability, provider, request,
system, users,
routing, system, users,
};
pub(crate) async fn maybe_build_local_admin_response(
@@ -20,6 +20,10 @@ pub(crate) async fn maybe_build_local_admin_response(
return Ok(Some(response));
}
if let Some(response) = routing::maybe_build_local_admin_routing_response(request).await? {
return Ok(Some(response));
}
if let Some(response) = auth::maybe_build_local_admin_auth_response(request).await? {
return Ok(Some(response));
}

View File

@@ -0,0 +1,760 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupBindingSubject, RoutingGroupLookupKey,
StoredRoutingGroup, StoredRoutingGroupBinding, StoredRoutingGroupVersion,
UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
};
use aether_routing_core::{
validate_routing_group_config, MutationPlan, RoutingGroupConfig, RoutingHeaderPatch,
RoutingPatchSummary, RoutingRulePhase,
};
use axum::{
body::{Body, Bytes},
http::{self, HeaderMap, HeaderName, HeaderValue},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::clock::current_unix_secs;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
use crate::routing::{
apply_routing_mutation_plan, build_routing_trace_seed, resolve_gateway_routing_policy,
GatewayRoutingPolicyInput,
};
use crate::GatewayError;
const ROUTING_GROUPS_ROOT: &str = "/api/admin/routing/groups";
const ROUTING_BINDINGS_ROOT: &str = "/api/admin/routing/bindings";
#[derive(Debug, Deserialize)]
struct AdminRoutingGroupCreateRequest {
#[serde(default)]
id: Option<String>,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default = "default_true")]
enabled: bool,
#[serde(default)]
is_system_default: bool,
#[serde(default)]
config_json: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct AdminRoutingGroupBindingCreateRequest {
#[serde(default)]
id: Option<String>,
group_id: String,
subject_type: RoutingGroupBindingSubject,
subject_id: String,
#[serde(default)]
is_default: bool,
#[serde(default)]
allow_explicit_select: bool,
}
#[derive(Debug, Deserialize)]
struct AdminRoutingDryRunRequest {
model: String,
#[serde(default)]
resolved_model: Option<String>,
#[serde(default = "default_api_format")]
api_format: String,
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
api_key_id: Option<String>,
#[serde(default)]
headers: Option<Value>,
#[serde(default)]
body: Option<Value>,
#[serde(default)]
phase: Option<RoutingRulePhase>,
}
pub(crate) async fn maybe_build_local_admin_routing_response(
request: crate::handlers::admin::request::AdminRouteRequest<'_>,
) -> crate::handlers::admin::request::AdminRouteResult {
let state = request.state();
let request_context = request.request_context();
let request_body = request.request_body();
if request_context.route_family() != Some("routing_profiles_manage") {
return Ok(None);
}
if !request_context.path().starts_with("/api/admin/routing/") {
return Ok(None);
}
if !state.has_routing_group_data_reader() {
return Ok(Some(data_unavailable_response()));
}
let response = if request_context.path().starts_with(ROUTING_GROUPS_ROOT) {
maybe_build_routing_groups_response(&state, &request_context, request_body).await?
} else if request_context.path().starts_with(ROUTING_BINDINGS_ROOT) {
maybe_build_routing_bindings_response(&state, &request_context, request_body).await?
} else {
None
};
Ok(response)
}
async fn maybe_build_routing_groups_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let path = normalized_admin_path(request_context.path());
match (request_context.method(), path.as_str()) {
(&http::Method::GET, ROUTING_GROUPS_ROOT) => {
let groups = state.list_routing_groups().await?;
Ok(Some(
Json(json!({
"items": groups.iter().map(routing_group_payload).collect::<Vec<_>>(),
"total": groups.len(),
}))
.into_response(),
))
}
(&http::Method::POST, ROUTING_GROUPS_ROOT) => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
let payload = parse_json_body::<AdminRoutingGroupCreateRequest>(request_body)?;
let config_json = payload.config_json.unwrap_or_else(|| json!({}));
validate_config_json(&config_json)?;
let now = current_unix_secs() as i64;
let record = CreateRoutingGroupRecord {
id: payload.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
name: payload.name,
description: payload.description,
enabled: payload.enabled,
is_system_default: payload.is_system_default,
config_json,
version: 1,
created_at: now,
updated_at: now,
published_at: None,
};
let Some(created) = state.create_routing_group(record).await? else {
return Ok(Some(data_unavailable_response()));
};
Ok(Some(attach_admin_audit_response(
Json(routing_group_payload(&created)).into_response(),
"admin_routing_group_created",
"create_routing_group",
"routing_group",
&created.id,
)))
}
_ => {
let Some((group_id, suffix)) = routing_group_path_parts(path.as_str()) else {
return Ok(None);
};
match (request_context.method(), suffix.as_deref()) {
(&http::Method::GET, None) => {
let Some(group) = state
.find_routing_group(RoutingGroupLookupKey::Id(&group_id))
.await?
else {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
};
Ok(Some(Json(routing_group_payload(&group)).into_response()))
}
(&http::Method::PATCH, None) => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
let patch = build_routing_group_update_patch(request_body)?;
let Some(updated) = state.update_routing_group(&group_id, patch).await? else {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
};
Ok(Some(attach_admin_audit_response(
Json(routing_group_payload(&updated)).into_response(),
"admin_routing_group_updated",
"update_routing_group",
"routing_group",
&updated.id,
)))
}
(&http::Method::DELETE, None) => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
if !state.delete_routing_group(&group_id).await? {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
}
Ok(Some(attach_admin_audit_response(
http::StatusCode::NO_CONTENT.into_response(),
"admin_routing_group_deleted",
"delete_routing_group",
"routing_group",
&group_id,
)))
}
(&http::Method::POST, Some("publish")) => {
publish_routing_group(state, &group_id).await
}
(&http::Method::GET, Some("versions")) => {
let versions = state.list_routing_group_versions(&group_id).await?;
Ok(Some(Json(json!({
"items": versions.iter().map(routing_group_version_payload).collect::<Vec<_>>(),
"total": versions.len(),
})).into_response()))
}
(&http::Method::POST, Some("dry-run")) => {
dry_run_routing_group(state, &group_id, request_body).await
}
_ => Ok(None),
}
}
}
}
async fn maybe_build_routing_bindings_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let path = normalized_admin_path(request_context.path());
match (request_context.method(), path.as_str()) {
(&http::Method::GET, ROUTING_BINDINGS_ROOT) => {
let query = routing_binding_query_from_request(request_context)?;
let bindings = state.list_routing_group_bindings(&query).await?;
Ok(Some(
Json(json!({
"items": bindings.iter().map(routing_group_binding_payload).collect::<Vec<_>>(),
"total": bindings.len(),
}))
.into_response(),
))
}
(&http::Method::POST, ROUTING_BINDINGS_ROOT) => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
let payload = parse_json_body::<AdminRoutingGroupBindingCreateRequest>(request_body)?;
let now = current_unix_secs() as i64;
let record = CreateRoutingGroupBindingRecord {
id: payload.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
group_id: payload.group_id,
subject_type: payload.subject_type,
subject_id: payload.subject_id,
is_default: payload.is_default,
allow_explicit_select: payload.allow_explicit_select,
created_at: now,
updated_at: now,
};
let Some(created) = state.create_routing_group_binding(record).await? else {
return Ok(Some(data_unavailable_response()));
};
Ok(Some(attach_admin_audit_response(
Json(routing_group_binding_payload(&created)).into_response(),
"admin_routing_group_binding_created",
"create_routing_group_binding",
"routing_group_binding",
&created.id,
)))
}
_ => {
let Some(binding_id) = routing_binding_id_from_path(path.as_str()) else {
return Ok(None);
};
match *request_context.method() {
http::Method::PATCH => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
let patch = build_routing_binding_update_patch(request_body)?;
let Some(updated) = state
.update_routing_group_binding(&binding_id, patch)
.await?
else {
return Ok(Some(not_found_response(format!(
"routing group binding {binding_id} not found"
))));
};
Ok(Some(attach_admin_audit_response(
Json(routing_group_binding_payload(&updated)).into_response(),
"admin_routing_group_binding_updated",
"update_routing_group_binding",
"routing_group_binding",
&updated.id,
)))
}
http::Method::DELETE => {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
if !state.delete_routing_group_binding(&binding_id).await? {
return Ok(Some(not_found_response(format!(
"routing group binding {binding_id} not found"
))));
}
Ok(Some(attach_admin_audit_response(
http::StatusCode::NO_CONTENT.into_response(),
"admin_routing_group_binding_deleted",
"delete_routing_group_binding",
"routing_group_binding",
&binding_id,
)))
}
_ => Ok(None),
}
}
}
}
async fn publish_routing_group(
state: &AdminAppState<'_>,
group_id: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
if !state.has_routing_group_data_writer() {
return Ok(Some(data_unavailable_response()));
}
let Some(group) = state
.find_routing_group(RoutingGroupLookupKey::Id(group_id))
.await?
else {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
};
validate_config_json(&group.config_json)?;
let latest_version = state
.list_routing_group_versions(group_id)
.await?
.into_iter()
.map(|version| version.version)
.max()
.unwrap_or(0);
let next_version = group.version.max(latest_version.saturating_add(1));
let now = current_unix_secs() as i64;
let Some(updated) = state
.update_routing_group(
group_id,
UpdateRoutingGroupRecord {
version: Some(next_version),
updated_at: now,
published_at: Some(Some(now)),
..UpdateRoutingGroupRecord::default()
},
)
.await?
else {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
};
let _ = state
.create_routing_group_version(CreateRoutingGroupVersionRecord {
id: Uuid::new_v4().to_string(),
group_id: group_id.to_string(),
version: next_version,
config_json: updated.config_json.clone(),
created_at: now,
created_by: None,
})
.await?;
Ok(Some(attach_admin_audit_response(
Json(routing_group_payload(&updated)).into_response(),
"admin_routing_group_published",
"publish_routing_group",
"routing_group",
group_id,
)))
}
async fn dry_run_routing_group(
state: &AdminAppState<'_>,
group_id: &str,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(group) = state
.find_routing_group(RoutingGroupLookupKey::Id(group_id))
.await?
else {
return Ok(Some(not_found_response(format!(
"routing group {group_id} not found"
))));
};
let payload = parse_json_body::<AdminRoutingDryRunRequest>(request_body)?;
let requested_model = payload.model.trim();
if requested_model.is_empty() {
return Ok(Some(bad_request_response("model must not be empty")));
}
let resolved_model = payload
.resolved_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(requested_model);
let api_format = payload.api_format.trim();
let headers_json = payload.headers.unwrap_or_else(|| json!({}));
let mut header_map = header_map_from_value(&headers_json)?;
let mut body = payload.body.unwrap_or_else(|| json!({}));
let policy = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
group_id: Some(group.id.as_str()),
group_version: Some(group.version),
group_config_json: &group.config_json,
selection_source: "admin_dry_run",
requested_model,
resolved_model,
api_format,
user_id: payload.user_id.as_deref(),
api_key_id: payload.api_key_id.as_deref(),
headers: &headers_json,
body: &body,
phase: payload.phase.unwrap_or(RoutingRulePhase::ClientRequest),
})?;
let patch_summary = patch_summary(&policy.mutation_plan);
apply_routing_mutation_plan(&mut body, &mut header_map, &policy.mutation_plan)?;
let mut trace = build_routing_trace_seed(&policy, api_format);
trace.client_request_patch_summary = patch_summary.clone();
Ok(Some(Json(json!({
"group": routing_group_payload(&group),
"policy": policy,
"trace_seed": trace,
"patch_summary": patch_summary,
"mutated_body": body,
"mutated_headers": header_map_payload(&header_map),
"candidate_preview": {
"status": "policy_only",
"ranking_overlay": policy.ranking_overlay,
"note": "full candidate preview is produced by runtime materialization once provider/key catalogs are enumerated"
}
})).into_response()))
}
fn build_routing_group_update_patch(
request_body: Option<&Bytes>,
) -> Result<UpdateRoutingGroupRecord, GatewayError> {
let raw = parse_json_value_body(request_body)?;
let Some(object) = raw.as_object() else {
return Err(bad_request_error("request body must be a JSON object"));
};
let mut patch = UpdateRoutingGroupRecord {
updated_at: current_unix_secs() as i64,
..UpdateRoutingGroupRecord::default()
};
if let Some(value) = object.get("name") {
patch.name = Some(required_string(value, "name")?);
}
if let Some(value) = object.get("description") {
patch.description = Some(optional_string(value, "description")?);
}
if let Some(value) = object.get("enabled") {
patch.enabled = Some(required_bool(value, "enabled")?);
}
if let Some(value) = object.get("is_system_default") {
patch.is_system_default = Some(required_bool(value, "is_system_default")?);
}
if let Some(value) = object.get("config_json") {
validate_config_json(value)?;
patch.config_json = Some(value.clone());
patch.version = object
.get("version")
.and_then(Value::as_i64)
.or(Some(current_unix_secs() as i64));
} else if let Some(value) = object.get("version") {
patch.version = Some(required_i64(value, "version")?.max(1));
}
if let Some(value) = object.get("published_at") {
patch.published_at = Some(optional_i64(value, "published_at")?);
}
Ok(patch)
}
fn build_routing_binding_update_patch(
request_body: Option<&Bytes>,
) -> Result<UpdateRoutingGroupBindingRecord, GatewayError> {
let raw = parse_json_value_body(request_body)?;
let Some(object) = raw.as_object() else {
return Err(bad_request_error("request body must be a JSON object"));
};
let mut patch = UpdateRoutingGroupBindingRecord {
updated_at: current_unix_secs() as i64,
..UpdateRoutingGroupBindingRecord::default()
};
if let Some(value) = object.get("group_id") {
patch.group_id = Some(required_string(value, "group_id")?);
}
if let Some(value) = object.get("subject_type") {
patch.subject_type = Some(routing_subject_from_value(value)?);
}
if let Some(value) = object.get("subject_id") {
patch.subject_id = Some(required_string(value, "subject_id")?);
}
if let Some(value) = object.get("is_default") {
patch.is_default = Some(required_bool(value, "is_default")?);
}
if let Some(value) = object.get("allow_explicit_select") {
patch.allow_explicit_select = Some(required_bool(value, "allow_explicit_select")?);
}
Ok(patch)
}
fn routing_binding_query_from_request(
request_context: &AdminRequestContext<'_>,
) -> Result<RoutingGroupBindingQuery, GatewayError> {
let subject_type = query_param_value(request_context.query_string(), "subject_type")
.map(|value| routing_subject_from_str(&value))
.transpose()?;
Ok(RoutingGroupBindingQuery {
group_id: query_param_value(request_context.query_string(), "group_id"),
subject_type,
subject_id: query_param_value(request_context.query_string(), "subject_id"),
})
}
fn validate_config_json(value: &Value) -> Result<(), GatewayError> {
if !value.is_object() {
return Err(bad_request_error("config_json must be a JSON object"));
}
let config = serde_json::from_value::<RoutingGroupConfig>(value.clone())
.map_err(|err| bad_request_error(format!("config_json is invalid: {err}")))?;
validate_routing_group_config(&config)
.map_err(|err| bad_request_error(format!("config_json is invalid: {err}")))
}
fn parse_json_body<T>(request_body: Option<&Bytes>) -> Result<T, GatewayError>
where
T: for<'de> Deserialize<'de>,
{
let raw = request_body.ok_or_else(|| bad_request_error("request body is required"))?;
serde_json::from_slice(raw)
.map_err(|err| bad_request_error(format!("request body must be valid JSON: {err}")))
}
fn parse_json_value_body(request_body: Option<&Bytes>) -> Result<Value, GatewayError> {
parse_json_body::<Value>(request_body)
}
fn header_map_from_value(value: &Value) -> Result<HeaderMap, GatewayError> {
let Some(object) = value.as_object() else {
return Err(bad_request_error("headers must be a JSON object"));
};
let mut headers = HeaderMap::new();
for (name, value) in object {
let Some(value) = value.as_str() else {
return Err(bad_request_error(format!(
"header {name} must have a string value"
)));
};
let header_name = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| bad_request_error(format!("header {name} has invalid name")))?;
let header_value = HeaderValue::from_str(value)
.map_err(|_| bad_request_error(format!("header {name} has invalid value")))?;
headers.insert(header_name, header_value);
}
Ok(headers)
}
fn header_map_payload(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_string(), value.to_string()))
})
.collect()
}
fn patch_summary(plan: &MutationPlan) -> RoutingPatchSummary {
RoutingPatchSummary {
body_paths: plan
.body_patch
.iter()
.map(|operation| operation.path().to_string())
.collect(),
header_names: plan
.header_patch
.iter()
.map(|operation| match operation {
RoutingHeaderPatch::Set { name, .. } | RoutingHeaderPatch::Remove { name } => {
name.clone()
}
})
.collect(),
failed_action: None,
}
}
fn routing_group_payload(group: &StoredRoutingGroup) -> Value {
json!({
"id": group.id,
"name": group.name,
"description": group.description,
"enabled": group.enabled,
"is_system_default": group.is_system_default,
"config_json": group.config_json,
"version": group.version,
"created_at": group.created_at,
"updated_at": group.updated_at,
"published_at": group.published_at,
})
}
fn routing_group_binding_payload(binding: &StoredRoutingGroupBinding) -> Value {
json!({
"id": binding.id,
"group_id": binding.group_id,
"subject_type": binding.subject_type,
"subject_id": binding.subject_id,
"is_default": binding.is_default,
"allow_explicit_select": binding.allow_explicit_select,
"created_at": binding.created_at,
"updated_at": binding.updated_at,
})
}
fn routing_group_version_payload(version: &StoredRoutingGroupVersion) -> Value {
json!({
"id": version.id,
"group_id": version.group_id,
"version": version.version,
"config_json": version.config_json,
"created_at": version.created_at,
"created_by": version.created_by,
})
}
fn normalized_admin_path(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
trimmed.to_string()
}
}
fn routing_group_path_parts(path: &str) -> Option<(String, Option<String>)> {
let suffix = path.strip_prefix(&(ROUTING_GROUPS_ROOT.to_string() + "/"))?;
let mut parts = suffix.split('/');
let group_id = parts.next()?.trim();
if group_id.is_empty() {
return None;
}
let suffix = parts.next().map(str::to_string);
if parts.next().is_some() {
return None;
}
Some((group_id.to_string(), suffix))
}
fn routing_binding_id_from_path(path: &str) -> Option<String> {
let suffix = path.strip_prefix(&(ROUTING_BINDINGS_ROOT.to_string() + "/"))?;
if suffix.trim().is_empty() || suffix.contains('/') {
return None;
}
Some(suffix.to_string())
}
fn routing_subject_from_value(value: &Value) -> Result<RoutingGroupBindingSubject, GatewayError> {
let Some(value) = value.as_str() else {
return Err(bad_request_error("subject_type must be a string"));
};
routing_subject_from_str(value)
}
fn routing_subject_from_str(value: &str) -> Result<RoutingGroupBindingSubject, GatewayError> {
match value.trim() {
"user" => Ok(RoutingGroupBindingSubject::User),
"api_key" => Ok(RoutingGroupBindingSubject::ApiKey),
"user_group" => Ok(RoutingGroupBindingSubject::UserGroup),
other => Err(bad_request_error(format!(
"unsupported subject_type: {other}"
))),
}
}
fn required_string(value: &Value, field: &str) -> Result<String, GatewayError> {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| bad_request_error(format!("{field} must be a non-empty string")))
}
fn optional_string(value: &Value, field: &str) -> Result<Option<String>, GatewayError> {
if value.is_null() {
return Ok(None);
}
required_string(value, field).map(Some)
}
fn required_bool(value: &Value, field: &str) -> Result<bool, GatewayError> {
value
.as_bool()
.ok_or_else(|| bad_request_error(format!("{field} must be a boolean")))
}
fn required_i64(value: &Value, field: &str) -> Result<i64, GatewayError> {
value
.as_i64()
.ok_or_else(|| bad_request_error(format!("{field} must be an integer")))
}
fn optional_i64(value: &Value, field: &str) -> Result<Option<i64>, GatewayError> {
if value.is_null() {
return Ok(None);
}
required_i64(value, field).map(Some)
}
fn default_true() -> bool {
true
}
fn default_api_format() -> String {
"openai:chat".to_string()
}
fn bad_request_error(detail: impl Into<String>) -> GatewayError {
GatewayError::Client {
status: http::StatusCode::BAD_REQUEST,
message: detail.into(),
}
}
fn bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn not_found_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": "routing profile data backend is unavailable" })),
)
.into_response()
}

View File

@@ -300,6 +300,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
http::Method::POST,
Some("query_models" | "test_model" | "test_model_failover"),
)
| (Some("routing_profiles_manage"), http::Method::POST, Some("create_group"))
| (Some("routing_profiles_manage"), http::Method::PATCH, Some("update_group"))
| (Some("routing_profiles_manage"), http::Method::POST, Some("dry_run_group"))
| (Some("routing_profiles_manage"), http::Method::POST, Some("create_binding"))
| (Some("routing_profiles_manage"), http::Method::PATCH, Some("update_binding"))
| (Some("billing_manage"), http::Method::POST, Some("apply_preset"))
| (Some("billing_manage"), http::Method::POST, Some("create_rule"))
| (Some("billing_manage"), http::Method::PUT, Some("update_rule"))