Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
81 changes: 72 additions & 9 deletions crates/aisix-core/src/models/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,19 @@ pub struct Model {
#[schemars(length(min = 1, max = 255))]
pub pricing_key: Option<String>,

/// Opaque control-plane-issued canonical, non-nil UUID that authorizes a
/// concrete wildcard-upstream model name for pricing. It is relevant only
/// to a direct-shaped wildcard model (chat or embedding); without it the
/// data plane deliberately omits the resolved model from terminal
/// telemetry so the control plane can leave the call unpriced rather than
/// trust mutable configuration.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(
regex(pattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"),
length(min = 36, max = 64)
)]
pub pricing_authority_id: Option<String>,

/// Direct-model-only background health-check configuration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_model_check: Option<BackgroundModelCheck>,
Expand Down Expand Up @@ -421,6 +434,9 @@ impl Model {
if self.effort_mapping.take().is_some() {
stripped.push("effort_mapping");
}
if self.pricing_authority_id.take().is_some() {
stripped.push("pricing_authority_id");
}
if self.pricing_key.take().is_some() {
stripped.push("pricing_key");
}
Expand Down Expand Up @@ -533,8 +549,8 @@ pub fn model_one_of() -> Value {
/// [`Model::strip_kind_inapplicable`]). Kind policy (project decision):
/// generic call knobs (`timeout`/`stream_timeout`/`retries`) resolve
/// member → group → deployment default wherever a group slot exists;
/// model-specific knobs (`auto_prompt_caching`, `cost`, `pricing_key`) are
/// direct-only.
/// model-specific knobs (`auto_prompt_caching`, `cost`, `pricing_key`,
/// `pricing_authority_id`) are direct-shaped-only.
pub fn model_one_of_strict() -> Value {
model_one_of_variant(true)
}
Expand All @@ -559,6 +575,7 @@ fn model_one_of_variant(strict: bool) -> Value {
"auto_prompt_caching",
"cost",
"pricing_key",
"pricing_authority_id",
"effort_mapping",
],
);
Expand All @@ -580,6 +597,7 @@ fn model_one_of_variant(strict: bool) -> Value {
"auto_prompt_caching",
"cost",
"pricing_key",
"pricing_authority_id",
"effort_mapping",
],
);
Expand All @@ -592,6 +610,7 @@ fn model_one_of_variant(strict: bool) -> Value {
"auto_prompt_caching",
"cost",
"pricing_key",
"pricing_authority_id",
"effort_mapping",
],
);
Expand Down Expand Up @@ -694,6 +713,23 @@ mod tests {
assert_eq!(m.rate_limit.as_ref().unwrap().rpm, Some(100));
}

#[test]
fn pricing_authority_id_round_trips_only_when_set() {
let mut model: Model = serde_json::from_str(sample_json()).unwrap();
assert!(model.pricing_authority_id.is_none());
assert!(serde_json::to_value(&model)
.unwrap()
.get("pricing_authority_id")
.is_none());

model.pricing_authority_id = Some("a3ebdc63-e921-4323-a75c-3b911f950046".to_string());
let encoded = serde_json::to_value(&model).unwrap();
assert_eq!(
encoded["pricing_authority_id"],
serde_json::json!("a3ebdc63-e921-4323-a75c-3b911f950046")
);
}

#[test]
fn deserialises_stream_timeout_and_helpers_fold_zero() {
let m: Model = serde_json::from_str(
Expand Down Expand Up @@ -762,11 +798,20 @@ mod tests {
"retries": 2,
"timeout": 1000,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0},
"auto_prompt_caching": {"enabled": true}
"auto_prompt_caching": {"enabled": true},
"pricing_authority_id": "a3ebdc63-e921-4323-a75c-3b911f950046"
}));
let mut stripped = group.strip_kind_inapplicable();
stripped.sort_unstable();
assert_eq!(stripped, ["auto_prompt_caching", "cost", "retries"]);
assert_eq!(
stripped,
[
"auto_prompt_caching",
"cost",
"pricing_authority_id",
"retries"
]
);
assert!(group.retries.is_none() && group.cost.is_none());
assert_eq!(group.timeout, Some(1000));
// Semantic parent: timeout/retries are the group slots and stay.
Expand All @@ -780,9 +825,13 @@ mod tests {
},
"retries": 2,
"timeout": 1000,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0},
"pricing_authority_id": "a3ebdc63-e921-4323-a75c-3b911f950046"
}));
assert_eq!(sem.strip_kind_inapplicable(), ["cost"]);
assert_eq!(
sem.strip_kind_inapplicable(),
["cost", "pricing_authority_id"]
);
assert_eq!(sem.retries, Some(2));
assert_eq!(sem.timeout, Some(1000));
// Direct: nothing strips.
Expand All @@ -792,10 +841,15 @@ mod tests {
"model_name": "gpt-4o",
"provider_key_id": "pk-1",
"retries": 2,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0},
"pricing_authority_id": "a3ebdc63-e921-4323-a75c-3b911f950046"
}));
assert!(direct.strip_kind_inapplicable().is_empty());
assert_eq!(direct.retries, Some(2));
assert_eq!(
direct.pricing_authority_id.as_deref(),
Some("a3ebdc63-e921-4323-a75c-3b911f950046")
);
// Ensemble parent: the whole generic set strips (its own
// deadline knob is `ensemble.timeout_ms`).
let mut ens = load(serde_json::json!({
Expand All @@ -804,15 +858,24 @@ mod tests {
"timeout": 1000,
"stream_timeout": 500,
"retries": 1,
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0}
"cost": {"input_per_1k": 0.0, "output_per_1k": 0.0},
"pricing_authority_id": "a3ebdc63-e921-4323-a75c-3b911f950046",
"pricing_key": "catalog-gpt"
}));
// Asserted WITHOUT a pre-sort: the strip output is already
// lexicographic (a pure-strip loader row keeps the fields
// "sorted" per PartialCompatRow's contract).
let ens_stripped = ens.strip_kind_inapplicable();
assert_eq!(
ens_stripped,
["cost", "retries", "stream_timeout", "timeout"]
[
"cost",
"pricing_authority_id",
"pricing_key",
"retries",
"stream_timeout",
"timeout"
]
);
}

Expand Down
53 changes: 52 additions & 1 deletion crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,19 @@ pub fn model_root_schema(strict: bool) -> Value {
.as_object_mut()
.expect("model root schema is a JSON object")
.insert("oneOf".to_string(), one_of);
if strict {
// CP-issued pricing authorities are canonical UUIDs, but UUID nil is
// not an authority. Keep this write-only so an already-projected
// legacy row still loads and the DP can safely emit it unpriced.
schema
.pointer_mut("/properties/pricing_authority_id")
.and_then(Value::as_object_mut)
.expect("model schema declares pricing_authority_id")
.insert(
"not".to_string(),
json!({"const": "00000000-0000-0000-0000-000000000000"}),
);
}
// `OnEmbeddingFailure` is `#[serde(untagged)]` with an object variant
// (`{ "target": … }`): serde buffers untagged content and silently
// swallows unknown fields inside it, invisible to the write path's
Expand Down Expand Up @@ -6065,10 +6078,12 @@ mod tests {
"display_name": "g",
"routing": {"strategy": "failover", "targets": [{"model": "m"}]},
"retries": 3,
"cost": {"input_per_1k": 0.5, "output_per_1k": 1.5}
"cost": {"input_per_1k": 0.5, "output_per_1k": 1.5},
"pricing_authority_id": "a3ebdc63-e921-4323-a75c-3b911f950046"
});
let msg = validate_model(&group).unwrap_err().message;
assert!(msg.contains("`cost`"), "{msg}");
assert!(msg.contains("`pricing_authority_id`"), "{msg}");
assert!(msg.contains("`retries`"), "{msg}");
assert!(msg.contains("model group"), "{msg}");
assert!(
Expand Down Expand Up @@ -6114,6 +6129,42 @@ mod tests {
assert!(!msg.contains("semantic router"), "{msg}");
}

#[test]
fn pricing_authority_id_is_canonical_non_nil_on_write_and_lenient_on_read() {
let base = json!({
"display_name": "catalog/*",
"provider": "openai",
"model_name": "*",
"provider_key_id": "pk"
});
let mut valid = base.clone();
valid["pricing_authority_id"] = json!("a3ebdc63-e921-4323-a75c-3b911f950046");
validate_model(&valid).expect("canonical authority UUID writes");

for invalid in [
"00000000-0000-0000-0000-000000000000",
"A3EBDC63-E921-4323-A75C-3B911F950046",
"not-a-uuid",
] {
let mut model = base.clone();
model["pricing_authority_id"] = json!(invalid);
assert!(
validate_model(&model).is_err(),
"strict write must reject {invalid:?}"
);
}

let mut legacy = base;
legacy["pricing_authority_id"] = json!("00000000-0000-0000-0000-000000000000");
validate_model_lenient(&legacy).expect("legacy nil authority still loads unpriced");

let schema = model_root_schema(true);
assert_eq!(
schema["properties"]["pricing_authority_id"]["maxLength"],
json!(64)
);
}

#[test]
fn model_non_dead_knob_failures_keep_the_generic_message() {
// A failure that is NOT a dead knob must not be relabelled: the
Expand Down
4 changes: 4 additions & 0 deletions crates/aisix-core/tests/resource_schema_characterization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,10 @@ const EXTRA_RELAXATIONS: &[(&str, &[&str])] = &[
"/oneOf/3/not/anyOf",
"/properties/effort_mapping/additionalProperties/minLength",
"/properties/effort_mapping/properties//minLength",
// CP may have projected an older nil authority. The strict contract
// rejects it, while a DP must keep loading that row and emit its
// usage unpriced during a rolling upgrade.
"/properties/pricing_authority_id/not",
],
),
];
Expand Down
22 changes: 22 additions & 0 deletions crates/aisix-obs/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ pub struct UsageEvent {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub requested_model: String,

/// Concrete upstream model selected through a wildcard upstream template.
///
/// `model_id` remains the configured wildcard row so policy and
/// attribution remain stable, while this value is the provider model name
/// that was actually dispatched. The control plane uses it only when the
/// configured row's `model_name` contains `*`, to look up its catalog
/// price; it is otherwise absent so an alias over a fixed upstream can
/// never override its configured pricing identity through telemetry.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub resolved_pricing_model: String,

/// Canonical non-nil CP-issued UUID paired with `resolved_pricing_model`.
/// Both values are absent for older DP configuration or any request that
/// did not dispatch a concrete wildcard-template model.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub pricing_authority_id: String,

pub prompt_tokens: u32,
pub completion_tokens: u32,

Expand Down Expand Up @@ -1695,6 +1712,8 @@ mod tests {
model_id: "mod-uuid".into(),
api_key_id: "ak-uuid".into(),
requested_model: "smart-group".into(),
resolved_pricing_model: "gpt-4o-2024-08-06".into(),
pricing_authority_id: "a3ebdc63-e921-4323-a75c-3b911f950046".into(),
prompt_tokens: 12,
completion_tokens: 34,
upstream_latency_ms: 56,
Expand All @@ -1709,6 +1728,8 @@ mod tests {
// AISIX-Cloud#790: the client-sent alias rides next to model_id
// so the dashboard can show the group a routed request used.
assert!(json.contains(r#""requested_model":"smart-group""#));
assert!(json.contains(r#""resolved_pricing_model":"gpt-4o-2024-08-06""#));
assert!(json.contains(r#""pricing_authority_id":"a3ebdc63-e921-4323-a75c-3b911f950046""#));
assert!(json.contains(r#""prompt_tokens":12"#));
assert!(json.contains(r#""completion_tokens":34"#));
assert!(json.contains(r#""guardrail_blocked":false"#));
Expand Down Expand Up @@ -1767,6 +1788,7 @@ mod tests {
assert!(!json.contains("reasoning_tokens"));
assert!(!json.contains("cache_creation_tokens"));
assert!(!json.contains("cache_read_tokens"));
assert!(!json.contains("pricing_authority_id"));
assert!(!json.contains("provider_request_id"));
assert!(!json.contains("provider_model_version"));
assert!(!json.contains("finish_reason"));
Expand Down
67 changes: 66 additions & 1 deletion crates/aisix-proxy/src/attribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ pub(crate) struct Resolved {
/// it at read time, so the pair is byte-identical to the one the
/// success path emits.
pub provider_key_id: String,
/// The concrete upstream model produced by the wildcard-resolution
/// branch, paired with the configured wildcard row that produced it.
/// Empty for exact model resolution, including a request that literally
/// names a wildcard row. Telemetry uses this only for an event that
/// actually dispatched that same row; it is not an access-log identity.
pub wildcard_pricing_model_id: String,
/// Canonical non-nil UUID issued by CP that makes the concrete model below
/// billable. This stays beside the captured model id so a terminal emitter
/// can either send the complete authority tuple or omit it entirely.
pub wildcard_pricing_authority_id: String,
pub wildcard_pricing_model: String,
/// Which cache layer answered this request, once one has — `Some`
/// exactly when the response came out of the cache.
///
Expand Down Expand Up @@ -840,6 +851,55 @@ pub(crate) fn note_target(model: &Model, provider_key_id: &str) {
});
}

/// Record the complete pricing authority a caller-addressed wildcard row
/// resolved to.
///
/// This is deliberately separate from [`note_target`]: an exact request for
/// the literal wildcard row also has an upstream model name, but it never
/// passed wildcard capture and must not be used as a pricing identity. The
/// authority is optional for rolling upgrades; without it, retain nothing so
/// the terminal event cannot assert a concrete wildcard price.
pub(crate) fn note_wildcard_pricing_identity(
model_id: &str,
pricing_authority_id: Option<&str>,
concrete_model: &str,
) {
let Some(pricing_authority_id) = pricing_authority_id.filter(|id| !id.is_empty()) else {
return;
};
if model_id.is_empty()
|| !valid_wildcard_pricing_model(concrete_model)
|| !valid_pricing_authority_id(pricing_authority_id)
{
return;
}
with(|r| {
r.wildcard_pricing_model_id = model_id.to_string();
r.wildcard_pricing_authority_id = pricing_authority_id.to_string();
r.wildcard_pricing_model = concrete_model.to_string();
});
}

// Keep this in step with AISIX Cloud's model-pricing name bound. A wildcard
// capture can be a valid upstream model name while still being too long to
// become a safe CP pricing lookup key; omit the entire optional tuple so its
// terminal parent event remains observable and explicitly unpriced.
const MAX_WILDCARD_PRICING_MODEL_CHARS: usize = 120;

fn valid_wildcard_pricing_model(model: &str) -> bool {
!model.is_empty()
&& !model.contains('*')
&& !model.contains('\0')
&& model.chars().count() <= MAX_WILDCARD_PRICING_MODEL_CHARS
}

fn valid_pricing_authority_id(id: &str) -> bool {
match uuid::Uuid::parse_str(id) {
Ok(parsed) => !parsed.is_nil() && parsed.to_string() == id,
Err(_) => false,
}
}

/// Overwrite the target half with what a CACHE HIT may honestly claim.
///
/// A hit contacts no upstream, so nothing was dispatched to and the line
Expand All @@ -858,7 +918,12 @@ pub(crate) fn note_target(model: &Model, provider_key_id: &str) {
/// group, whose candidate is no more the producer than any other.
pub(crate) fn note_cache_hit_entry(entry: &Model, hit_layer: &'static str) {
note_target(entry, entry.provider_key_id.as_deref().unwrap_or_default());
with(|r| r.cache_hit_layer = Some(hit_layer));
with(|r| {
r.cache_hit_layer = Some(hit_layer);
r.wildcard_pricing_model_id.clear();
r.wildcard_pricing_authority_id.clear();
r.wildcard_pricing_model.clear();
});
}

/// What the current request has resolved, or `None` outside a request.
Expand Down
Loading
Loading