Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ pub use error::{
};
pub use models::{
validate_apikey, validate_cache_policy, validate_guardrail, validate_model,
validate_observability_exporter, validate_provider_key, AisixSnapshot, ApiKey, CachePolicy,
CooldownConfig, ExporterKind, Guardrail, GuardrailHookPoint, GuardrailKind, KeywordConfig,
KeywordPattern, Model, ObservabilityExporter, OnAllFilteredPolicy, Provider, ProviderKey,
RateLimit, Routing, RoutingStrategy, RoutingTarget, SchemaError,
DEFAULT_COOLDOWN_TRIGGER_STATUSES,
validate_observability_exporter, validate_provider_key, validate_rate_limit_policy,
AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail,
GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter,
OnAllFilteredPolicy, Provider, ProviderKey, RateLimit, RateLimitPolicy, Routing,
RoutingStrategy, RoutingTarget, SchemaError, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
pub use resource::{Resource, ResourceEntry};
pub use snapshot::{ResourceTable, SnapshotHandle};
18 changes: 1 addition & 17 deletions crates/aisix-core/src/models/apikey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,11 @@ pub struct ApiKey {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,

/// Rate limit inherited from the owning team.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_rate_limit: Option<RateLimit>,

/// Org member who owns this key. Used as a limiter bucket key
/// (`member:<id>`) for member-level rate limiting.
Comment thread
nic-6443 marked this conversation as resolved.
Outdated
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,

/// Rate limit inherited from the owning member.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_rate_limit: Option<RateLimit>,

/// etcd-key uuid; filled by the loader, never in the JSON payload.
#[serde(skip)]
pub(crate) runtime_id: String,
Expand Down Expand Up @@ -163,9 +155,7 @@ mod tests {
allowed_models: vec![],
rate_limit: None,
team_id: None,
team_rate_limit: None,
owner_id: None,
owner_rate_limit: None,
runtime_id: String::new(),
};
assert!(!k.can_access("my-gpt4"));
Expand Down Expand Up @@ -237,24 +227,18 @@ mod tests {
"key_hash": "{SAMPLE_HASH}",
"allowed_models": ["gpt-4o"],
"team_id": "team-uuid-1",
"team_rate_limit": {{"rpm": 600}},
"owner_id": "member-uuid-1",
"owner_rate_limit": {{"tpm": 200000}}
"owner_id": "member-uuid-1"
}}"#
))
.unwrap();
assert_eq!(k.team_id.as_deref(), Some("team-uuid-1"));
assert_eq!(k.team_rate_limit.as_ref().unwrap().rpm, Some(600));
assert_eq!(k.owner_id.as_deref(), Some("member-uuid-1"));
assert_eq!(k.owner_rate_limit.as_ref().unwrap().tpm, Some(200000));
}

#[test]
fn absent_team_owner_fields_default_to_none() {
let k = sample();
assert!(k.team_id.is_none());
assert!(k.team_rate_limit.is_none());
assert!(k.owner_id.is_none());
assert!(k.owner_rate_limit.is_none());
}
}
5 changes: 4 additions & 1 deletion crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod model;
pub mod observability_exporter;
pub mod provider_key;
pub mod rate_limit;
pub mod rate_limit_policy;
pub mod routing;
pub mod schema;
pub mod snapshot;
Expand All @@ -38,9 +39,11 @@ pub use model::{
pub use observability_exporter::{ExporterKind, ObservabilityExporter, OtlpHttpConfig};
pub use provider_key::ProviderKey;
pub use rate_limit::RateLimit;
pub use rate_limit_policy::RateLimitPolicy;
pub use routing::{OnAllFilteredPolicy, Routing, RoutingStrategy, RoutingTarget};
pub use schema::{
validate_apikey, validate_cache_policy, validate_guardrail, validate_model,
validate_observability_exporter, validate_provider_key, SchemaError,
validate_observability_exporter, validate_provider_key, validate_rate_limit_policy,
SchemaError,
};
pub use snapshot::AisixSnapshot;
112 changes: 112 additions & 0 deletions crates/aisix-core/src/models/rate_limit_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
Comment thread
nic-6443 marked this conversation as resolved.

use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RateLimitPolicy {
pub name: String,
pub scope: String,
pub scope_ref: String,
pub window: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_requests: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,

#[serde(skip)]
pub(crate) runtime_id: String,
}

impl Resource for RateLimitPolicy {
fn id(&self) -> &str {
&self.runtime_id
}

#[allow(clippy::misnamed_getters)]
fn name(&self) -> &str {
&self.scope_ref
Comment thread
nic-6443 marked this conversation as resolved.
}
Comment thread
nic-6443 marked this conversation as resolved.

fn kind() -> &'static str {
"rate_limit_policies"
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialises_with_all_fields() {
let p: RateLimitPolicy = serde_json::from_str(
r#"{
"name": "team-quota",
"scope": "team",
"scope_ref": "team-uuid-1",
"window": "minute",
"max_requests": 100,
"max_tokens": 50000
}"#,
)
.unwrap();
assert_eq!(p.name, "team-quota");
assert_eq!(p.scope, "team");
assert_eq!(p.scope_ref, "team-uuid-1");
assert_eq!(p.window, "minute");
assert_eq!(p.max_requests, Some(100));
assert_eq!(p.max_tokens, Some(50000));
}

#[test]
fn deserialises_with_only_max_requests() {
let p: RateLimitPolicy = serde_json::from_str(
r#"{
"name": "key-rpm",
"scope": "api_key",
"scope_ref": "key-uuid-1",
"window": "minute",
"max_requests": 60
}"#,
)
.unwrap();
assert_eq!(p.max_requests, Some(60));
assert!(p.max_tokens.is_none());
}

#[test]
fn rejects_unknown_fields() {
let r: Result<RateLimitPolicy, _> = serde_json::from_str(
r#"{
"name": "x",
"scope": "team",
"scope_ref": "t1",
"window": "minute",
"extra": true
}"#,
);
assert!(r.is_err());
}

#[test]
fn resource_trait_returns_correct_kind() {
assert_eq!(RateLimitPolicy::kind(), "rate_limit_policies");
}

#[test]
fn resource_name_returns_scope_ref() {
let mut p: RateLimitPolicy = serde_json::from_str(
r#"{
"name": "test",
"scope": "member",
"scope_ref": "member-uuid-1",
"window": "hour",
"max_tokens": 1000000
}"#,
)
.unwrap();
p.runtime_id = "policy-1".into();
assert_eq!(p.id(), "policy-1");
assert_eq!(p.name(), "member-uuid-1");
}
}
109 changes: 103 additions & 6 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct Schemas {
pub guardrail: Validator,
pub cache_policy: Validator,
pub observability_exporter: Validator,
pub rate_limit_policy: Validator,
}

pub static SCHEMAS: Lazy<Arc<Schemas>> = Lazy::new(|| Arc::new(Schemas::compile()));
Expand All @@ -53,6 +54,9 @@ impl Schemas {
observability_exporter: jsonschema::options()
.build(&observability_exporter_schema())
.expect("observability_exporter schema is well-formed"),
rate_limit_policy: jsonschema::options()
.build(&rate_limit_policy_schema())
.expect("rate_limit_policy schema is well-formed"),
}
}
}
Expand Down Expand Up @@ -101,6 +105,10 @@ pub fn validate_observability_exporter(value: &Value) -> Result<(), SchemaError>
validate(&SCHEMAS.observability_exporter, value)
}

pub fn validate_rate_limit_policy(value: &Value) -> Result<(), SchemaError> {
validate(&SCHEMAS.rate_limit_policy, value)
}

fn model_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
Expand Down Expand Up @@ -248,9 +256,7 @@ fn apikey_schema() -> Value {
},
"rate_limit": { "$ref": "#/$defs/rate_limit" },
"team_id": { "type": "string", "minLength": 1 },
"team_rate_limit": { "$ref": "#/$defs/rate_limit" },
"owner_id": { "type": "string", "minLength": 1 },
"owner_rate_limit": { "$ref": "#/$defs/rate_limit" }
"owner_id": { "type": "string", "minLength": 1 }
Comment thread
nic-6443 marked this conversation as resolved.
},
"$defs": {
"rate_limit": {
Expand Down Expand Up @@ -439,6 +445,27 @@ fn observability_exporter_schema() -> Value {
})
}

fn rate_limit_policy_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["name", "scope", "scope_ref", "window"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1 },
"scope": { "type": "string", "enum": ["api_key", "model", "team", "member"] },
"scope_ref": { "type": "string", "minLength": 1 },
"window": { "type": "string", "enum": ["second", "minute", "hour"] },
"max_requests": { "type": "integer", "minimum": 1 },
"max_tokens": { "type": "integer", "minimum": 1 }
},
"anyOf": [
{ "required": ["max_requests"] },
{ "required": ["max_tokens"] }
]
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -564,9 +591,7 @@ mod tests {
"key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20",
"allowed_models":["gpt-4o"],
"team_id": "team-uuid-1",
"team_rate_limit": {"rpm": 600},
"owner_id": "member-uuid-1",
"owner_rate_limit": {"tpm": 200000}
"owner_id": "member-uuid-1"
});
validate_apikey(&v).unwrap();
}
Expand Down Expand Up @@ -848,4 +873,76 @@ mod tests {
// Phase 4 will add role_arn; today it's rejected.
assert!(validate_guardrail(&v).is_err());
}

// ---- rate_limit_policy schema tests ----

#[test]
fn rate_limit_policy_happy_path() {
let v = json!({
"name": "team-quota",
"scope": "team",
"scope_ref": "team-uuid-1",
"window": "minute",
"max_requests": 100,
"max_tokens": 50000
});
validate_rate_limit_policy(&v).unwrap();
}

#[test]
fn rate_limit_policy_rejects_unknown_scope() {
let v = json!({
"name": "bad",
"scope": "org",
"scope_ref": "x",
"window": "minute"
});
assert!(validate_rate_limit_policy(&v).is_err());
Comment thread
nic-6443 marked this conversation as resolved.
}

#[test]
fn rate_limit_policy_rejects_unknown_window() {
let v = json!({
"name": "bad",
"scope": "team",
"scope_ref": "x",
"window": "day"
});
assert!(validate_rate_limit_policy(&v).is_err());
}

#[test]
fn rate_limit_policy_rejects_extra_field() {
let v = json!({
"name": "bad",
"scope": "team",
"scope_ref": "x",
"window": "minute",
"extra": 1
});
assert!(validate_rate_limit_policy(&v).is_err());
}

#[test]
fn rate_limit_policy_rejects_zero_max_requests() {
let v = json!({
"name": "bad",
"scope": "team",
"scope_ref": "x",
"window": "minute",
"max_requests": 0
});
assert!(validate_rate_limit_policy(&v).is_err());
}

#[test]
fn rate_limit_policy_rejects_no_limits() {
let v = json!({
"name": "noop",
"scope": "team",
"scope_ref": "x",
"window": "minute"
});
assert!(validate_rate_limit_policy(&v).is_err());
}
}
3 changes: 3 additions & 0 deletions crates/aisix-core/src/models/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use super::guardrail::Guardrail;
use super::model::Model;
use super::observability_exporter::ObservabilityExporter;
use super::provider_key::ProviderKey;
use super::rate_limit_policy::RateLimitPolicy;
use crate::snapshot::ResourceTable;

/// Composite of every typed [`ResourceTable`] the gateway reads on the hot
Expand All @@ -27,6 +28,7 @@ pub struct AisixSnapshot {
/// Per-env observability exporters. Each enabled row receives a
/// fan-out POST per chat completion (see `aisix-obs::OtlpHttpFanOut`).
pub observability_exporters: ResourceTable<ObservabilityExporter>,
pub rate_limit_policies: ResourceTable<RateLimitPolicy>,
Comment thread
nic-6443 marked this conversation as resolved.
}

impl AisixSnapshot {
Expand All @@ -43,6 +45,7 @@ impl AisixSnapshot {
+ self.guardrails.len()
+ self.cache_policies.len()
+ self.observability_exporters.len()
+ self.rate_limit_policies.len()
}
}

Expand Down
Loading
Loading