use std::collections::{BTreeSet, HashSet};
use std::num::NonZeroU64;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{
CurrencyCode, GatewayContractError, GatewayDecimal, GatewayOperation, ModelRef, validate_text,
};
use crate::auth::PrincipalId;
use crate::ids::{ProviderDeploymentName, RoleName};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum FallbackScope {
Global,
Operation {
operation: GatewayOperation,
},
Model {
model: ModelRef,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct FallbackRule {
pub scope: FallbackScope,
pub candidates: Vec<ModelRef>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayFallbackPolicy {
pub rules: Vec<FallbackRule>,
}
impl GatewayFallbackPolicy {
pub fn validate(&self) -> Result<(), GatewayContractError> {
let mut scopes = HashSet::new();
for (index, rule) in self.rules.iter().enumerate() {
if !scopes.insert(&rule.scope) {
return Err(GatewayContractError::new(
format!("rules[{index}].scope"),
"at most one rule may exist for each scope",
));
}
let field = format!("rules[{index}].candidates");
validate_candidates(&field, &rule.candidates)?;
if let FallbackScope::Model { model } = &rule.scope
&& rule.candidates.contains(model)
{
return Err(GatewayContractError::new(
field,
"a model-scoped rule cannot list its own model",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayFallbackOverride {
pub candidates: Vec<ModelRef>,
}
impl GatewayFallbackOverride {
pub fn validate(&self) -> Result<(), GatewayContractError> {
validate_candidates("fallback.candidates", &self.candidates)
}
pub fn validate_for(&self, requested: &ModelRef) -> Result<(), GatewayContractError> {
validate_candidates("fallback.candidates", &self.candidates)?;
if self.candidates.contains(requested) {
return Err(GatewayContractError::new(
"fallback.candidates",
"an override cannot list the requested model",
));
}
Ok(())
}
pub fn to_header_value(&self) -> Result<String, GatewayContractError> {
let json = serde_jcs::to_vec(self)
.map_err(|_| GatewayContractError::new("fallback", "could not be serialized"))?;
Ok(URL_SAFE_NO_PAD.encode(json))
}
pub fn from_header_value(
value: &[u8],
requested: &ModelRef,
) -> Result<Self, GatewayContractError> {
let refuse = |reason| GatewayContractError::new("fallback", reason);
if value.len() > MAX_FALLBACK_HEADER_BYTES {
return Err(refuse("must be at most 8 KiB encoded"));
}
let json = URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| refuse("must be unpadded base64url"))?;
if json.len() > MAX_FALLBACK_JSON_BYTES {
return Err(refuse("must decode to at most 4 KiB"));
}
let fallback: Self = serde_json::from_slice(&json)
.map_err(|_| refuse("must be a JSON fallback override"))?;
fallback
.validate_for(requested)
.map_err(|error| refuse(error.reason))?;
Ok(fallback)
}
}
pub const FALLBACK_HEADER: &str = "wyrd-gateway-fallback";
pub const MAX_FALLBACK_HEADER_BYTES: usize = 8 * 1024;
pub const MAX_FALLBACK_JSON_BYTES: usize = 4 * 1024;
fn validate_candidates(field: &str, candidates: &[ModelRef]) -> Result<(), GatewayContractError> {
if candidates.is_empty() {
return Err(GatewayContractError::new(field, "must not be empty"));
}
let mut seen = HashSet::new();
if candidates.iter().any(|candidate| !seen.insert(candidate)) {
return Err(GatewayContractError::new(field, "must not repeat a model"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayPolicySubject {
Tenant,
Principal {
principal_id: PrincipalId,
},
Role {
role_name: RoleName,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayLimitSubject {
Tenant,
Principal {
principal_id: PrincipalId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayPolicyTarget {
All,
Deployment {
deployment: ProviderDeploymentName,
},
Model {
model: ModelRef,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayLimit {
pub subject: GatewayLimitSubject,
pub target: GatewayPolicyTarget,
#[cfg_attr(feature = "server", schema(value_type = Option<u64>, minimum = 1))]
pub requests_per_minute: Option<NonZeroU64>,
#[cfg_attr(feature = "server", schema(value_type = Option<u64>, minimum = 1))]
pub tokens_per_minute: Option<NonZeroU64>,
#[cfg_attr(feature = "server", schema(value_type = Option<u64>, minimum = 1))]
pub concurrent_calls: Option<NonZeroU64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum GatewayBudgetPeriod {
CalendarDayUtc,
CalendarMonthUtc,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum UnknownCostPolicy {
Reject,
#[default]
AllowUnpriced,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayBudget {
pub subject: GatewayPolicySubject,
pub period: GatewayBudgetPeriod,
pub amount: GatewayDecimal,
pub currency: CurrencyCode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayPriceRate {
pub dimension: String,
pub unit: String,
pub price: GatewayDecimal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayModelPricing {
pub model: ModelRef,
pub version: String,
pub currency: CurrencyCode,
pub effective_at: DateTime<Utc>,
pub active: bool,
pub rates: Vec<GatewayPriceRate>,
}
impl GatewayModelPricing {
#[must_use]
pub fn same_version_content(&self, other: &Self) -> bool {
self.model == other.model
&& self.version == other.version
&& self.currency == other.currency
&& self.effective_at == other.effective_at
&& self.rates == other.rates
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayGovernancePolicy {
pub limits: Vec<GatewayLimit>,
pub budgets: Vec<GatewayBudget>,
pub pricing: Vec<GatewayModelPricing>,
pub unknown_cost: UnknownCostPolicy,
}
impl GatewayGovernancePolicy {
pub fn validate(&self) -> Result<(), GatewayContractError> {
let mut limits = HashSet::new();
for (index, limit) in self.limits.iter().enumerate() {
if !limits.insert((&limit.subject, &limit.target)) {
return Err(GatewayContractError::new(
format!("limits[{index}]"),
"duplicate subject and target",
));
}
if limit.requests_per_minute.is_none()
&& limit.tokens_per_minute.is_none()
&& limit.concurrent_calls.is_none()
{
return Err(GatewayContractError::new(
format!("limits[{index}]"),
"at least one limit must be set",
));
}
}
let mut currency: Option<&CurrencyCode> = None;
let mut budgets = HashSet::new();
for (index, budget) in self.budgets.iter().enumerate() {
if !budgets.insert((&budget.subject, budget.period)) {
return Err(GatewayContractError::new(
format!("budgets[{index}]"),
"duplicate subject and period",
));
}
if budget.amount.is_zero() {
return Err(GatewayContractError::new(
format!("budgets[{index}].amount"),
"must be positive",
));
}
require_one_currency(
&mut currency,
&budget.currency,
format!("budgets[{index}].currency"),
)?;
}
let mut versions = HashSet::new();
let mut effective = HashSet::new();
for (index, entry) in self.pricing.iter().enumerate() {
validate_text(&format!("pricing[{index}].version"), &entry.version)?;
if !versions.insert((&entry.model, entry.version.as_str())) {
return Err(GatewayContractError::new(
format!("pricing[{index}]"),
"duplicate model and version",
));
}
if !effective.insert((&entry.model, entry.effective_at)) {
return Err(GatewayContractError::new(
format!("pricing[{index}]"),
"duplicate model and effective_at",
));
}
if entry.rates.is_empty() {
return Err(GatewayContractError::new(
format!("pricing[{index}].rates"),
"must not be empty",
));
}
for (rate_index, rate) in entry.rates.iter().enumerate() {
let field = format!("pricing[{index}].rates[{rate_index}]");
validate_text(&format!("{field}.dimension"), &rate.dimension)?;
validate_text(&format!("{field}.unit"), &rate.unit)?;
}
if entry.active {
require_one_currency(
&mut currency,
&entry.currency,
format!("pricing[{index}].currency"),
)?;
}
}
Ok(())
}
}
fn require_one_currency<'a>(
seen: &mut Option<&'a CurrencyCode>,
code: &'a CurrencyCode,
field: String,
) -> Result<(), GatewayContractError> {
match seen {
Some(existing) if *existing != code => Err(GatewayContractError::new(
field,
"budgets and active pricing must use one currency",
)),
_ => {
seen.get_or_insert(code);
Ok(())
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum GatewayCaptureMode {
#[default]
Disabled,
Metadata,
Payload,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
schemars::JsonSchema,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum GatewayPayloadField {
Request,
Response,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayCapturePolicyWrite {
pub mode: GatewayCaptureMode,
pub payload_fields: BTreeSet<GatewayPayloadField>,
}
impl GatewayCapturePolicyWrite {
pub fn validate(&self) -> Result<(), GatewayContractError> {
match (self.mode, self.payload_fields.is_empty()) {
(GatewayCaptureMode::Payload, true) => Err(GatewayContractError::new(
"payload_fields",
"payload mode requires at least one field",
)),
(GatewayCaptureMode::Disabled | GatewayCaptureMode::Metadata, false) => Err(
GatewayContractError::new("payload_fields", "must be empty unless mode is payload"),
),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GatewayCapturePolicy {
pub mode: GatewayCaptureMode,
pub payload_fields: BTreeSet<GatewayPayloadField>,
#[cfg_attr(feature = "server", schema(value_type = u64, minimum = 1))]
pub version: NonZeroU64,
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD};
use super::{
GatewayCapturePolicyWrite, GatewayFallbackOverride, GatewayFallbackPolicy,
GatewayGovernancePolicy, MAX_FALLBACK_HEADER_BYTES,
};
use crate::gateway::ModelRef;
use serde_json::{Value, json};
fn model(deployment: &str, name: &str) -> serde_json::Value {
json!({"deployment": deployment, "model": name})
}
#[test]
fn fallback_policy_rules_validate() {
let valid = json!({"rules": [
{"scope": "global", "candidates": [model("openai", "gpt-4o")]},
{"scope": {"operation": {"operation": "embeddings"}}, "candidates": [model("gemini", "e")]},
{"scope": {"model": {"model": model("openai", "gpt-4o")}}, "candidates": [model("anthropic", "claude")]},
]});
let policy: GatewayFallbackPolicy = serde_json::from_value(valid.clone()).expect("decodes");
policy.validate().expect("valid");
assert_eq!(serde_json::to_value(&policy).expect("encodes"), valid);
for rules in [
json!([{"scope": "global", "candidates": []}]),
json!([{"scope": "global", "candidates": [model("a-a", "x"), model("a-a", "x")]}]),
json!([
{"scope": "global", "candidates": [model("a-a", "x")]},
{"scope": "global", "candidates": [model("b-b", "y")]}
]),
json!([{"scope": {"model": {"model": model("a-a", "x")}}, "candidates": [model("a-a", "x")]}]),
] {
let policy: GatewayFallbackPolicy =
serde_json::from_value(json!({"rules": rules})).expect("decodes");
assert!(policy.validate().is_err(), "{rules}");
}
let requested = ModelRef::from_projection("openai/gpt-4o").expect("model");
let override_self: GatewayFallbackOverride =
serde_json::from_value(json!({"candidates": [model("openai", "gpt-4o")]}))
.expect("decodes");
assert!(override_self.validate_for(&requested).is_err());
}
#[test]
fn fallback_header_round_trips_and_refuses() {
let requested = ModelRef::from_projection("openai/gpt-4o").expect("model");
let fallback: GatewayFallbackOverride =
serde_json::from_value(json!({"candidates": [model("anthropic", "claude")]}))
.expect("decodes");
let value = fallback.to_header_value().expect("encodes");
assert_eq!(
URL_SAFE_NO_PAD.decode(&value).expect("base64url"),
br#"{"candidates":[{"deployment":"anthropic","model":"claude"}]}"#
);
assert_eq!(
GatewayFallbackOverride::from_header_value(value.as_bytes(), &requested),
Ok(fallback)
);
let encode = |document: Value| URL_SAFE_NO_PAD.encode(document.to_string());
let oversized_json = encode(json!({"candidates": [model("a-a", &"x".repeat(5000))]}));
for value in [
String::new(),
"not base64!".to_owned(),
URL_SAFE.encode(r#"{"candidates":[]}"#),
"A".repeat(MAX_FALLBACK_HEADER_BYTES + 1),
oversized_json,
encode(json!({"candidates": [model("a-a", "x")], "extra": true})),
encode(json!({"candidates": []})),
encode(json!({"candidates": [model("a-a", "x"), model("a-a", "x")]})),
encode(json!({"candidates": [model("openai", "gpt-4o")]})),
] {
let error = GatewayFallbackOverride::from_header_value(value.as_bytes(), &requested)
.expect_err("refused");
assert_eq!(error.field, "fallback", "{value}");
}
}
#[test]
fn governance_policy_rules_validate() {
assert_eq!(
serde_json::to_value(GatewayGovernancePolicy::default()).expect("encodes"),
json!({"limits": [], "budgets": [], "pricing": [], "unknown_cost": "allow_unpriced"})
);
let pricing = |version: &str, at: &str, currency: &str, active: bool| {
json!({
"model": model("openai", "gpt-4o"), "version": version, "currency": currency,
"effective_at": at, "active": active,
"rates": [{"dimension": "input_tokens", "unit": "1m_tokens", "price": "2.50"}],
})
};
let budget = |subject: serde_json::Value, currency: &str, amount: &str| json!({"subject": subject, "period": "calendar_month_utc", "amount": amount, "currency": currency});
let valid = json!({
"limits": [{"subject": "tenant", "target": "all", "requests_per_minute": 60, "tokens_per_minute": null, "concurrent_calls": null}],
"budgets": [budget(json!("tenant"), "USD", "100")],
"pricing": [pricing("v1", "2026-01-01T00:00:00Z", "USD", true), pricing("v0", "2025-01-01T00:00:00Z", "EUR", false)],
"unknown_cost": "reject",
});
let policy: GatewayGovernancePolicy = serde_json::from_value(valid).expect("decodes");
policy
.validate()
.expect("valid; retired pricing may keep an old currency");
let invalid = [
json!({"budgets": [budget(json!("tenant"), "USD", "1"), budget(json!("tenant"), "USD", "2")]}),
json!({"budgets": [budget(json!("tenant"), "USD", "0")]}),
json!({"budgets": [budget(json!("tenant"), "USD", "1")], "pricing": [pricing("v1", "2026-01-01T00:00:00Z", "EUR", true)]}),
json!({"pricing": [pricing("v1", "2026-01-01T00:00:00Z", "USD", true), pricing("v1", "2026-02-01T00:00:00Z", "USD", true)]}),
json!({"pricing": [pricing("v1", "2026-01-01T00:00:00Z", "USD", true), pricing("v2", "2026-01-01T00:00:00Z", "USD", true)]}),
json!({"limits": [{"subject": "tenant", "target": "all", "requests_per_minute": null, "tokens_per_minute": null, "concurrent_calls": null}]}),
];
for mut case in invalid {
for key in ["limits", "budgets", "pricing"] {
case.as_object_mut()
.expect("object")
.entry(key)
.or_insert(json!([]));
}
case["unknown_cost"] = json!("allow_unpriced");
let policy: GatewayGovernancePolicy =
serde_json::from_value(case.clone()).expect("decodes");
assert!(policy.validate().is_err(), "{case}");
}
assert!(
serde_json::from_value::<GatewayGovernancePolicy>(json!({
"limits": [{"subject": {"role": {"role_name": "viewer"}}, "target": "all", "requests_per_minute": 1, "tokens_per_minute": null, "concurrent_calls": null}],
"budgets": [], "pricing": [], "unknown_cost": "reject",
}))
.is_err(),
"limits have no role subject"
);
}
#[test]
fn capture_policy_field_combinations_validate() {
for (mode, fields, ok) in [
("disabled", json!([]), true),
("metadata", json!([]), true),
("payload", json!(["response", "request"]), true),
("payload", json!([]), false),
("metadata", json!(["request"]), false),
] {
let write: GatewayCapturePolicyWrite =
serde_json::from_value(json!({"mode": mode, "payload_fields": fields}))
.expect("decodes");
assert_eq!(write.validate().is_ok(), ok, "{mode} {fields}");
}
}
}