diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index c061e0e3..ac898c92 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -34,8 +34,9 @@ pub use models::{ validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, Adapter, AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail, GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter, - OnAllFilteredPolicy, Provider, ProviderKey, RateLimit, RateLimitPolicy, Routing, - RoutingStrategy, RoutingTarget, SchemaError, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES, + OnAllFilteredPolicy, ParamConstraints, Provider, ProviderKey, RateLimit, RateLimitPolicy, + RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, SchemaError, + StreamDoneMarker, TelemetryTags, DEFAULT_COOLDOWN_TRIGGER_STATUSES, }; pub use resource::{Resource, ResourceEntry}; pub use snapshot::{ResourceTable, SnapshotHandle}; diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 064c11e0..07840e02 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -38,7 +38,10 @@ pub use model::{ DEFAULT_COOLDOWN_TRIGGER_STATUSES, }; pub use observability_exporter::{ExporterKind, ObservabilityExporter, OtlpHttpConfig}; -pub use provider_key::{ProviderKey, TelemetryTags}; +pub use provider_key::{ + ParamConstraints, ProviderKey, RequestOverrides, ResponseOverrides, StreamDoneMarker, + TelemetryTags, +}; pub use rate_limit::RateLimit; pub use rate_limit_policy::RateLimitPolicy; pub use routing::{OnAllFilteredPolicy, Routing, RoutingStrategy, RoutingTarget}; diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index 4d148970..437377dc 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -13,12 +13,20 @@ //! etcd path: `{prefix}/provider_keys/{uuid}`. Secondary index on //! `display_name`. +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use crate::models::Adapter; use crate::resource::Resource; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +// `PartialEq` (not `Eq`) on `ProviderKey` because `RequestOverrides` +// carries `f64` (in `ParamConstraints`) and `serde_json::Value` (in +// `default_body_fields`), neither of which can implement `Eq` due to +// NaN / Number-equality semantics. Tests compare via `assert_eq!` +// which only needs `PartialEq`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ProviderKey { /// Operator-facing label, unique within the gateway. Surfaces in @@ -66,6 +74,20 @@ pub struct ProviderKey { #[serde(default)] pub telemetry_tags: TelemetryTags, + /// Per-key request-shape overrides — see issue #302 §5 + /// `RuntimeConfig.request`. `None` until cp-api ships the block. + /// No dispatch path reads it in this PR; #301 already provides + /// the primitive apply functions in `aisix-provider-openai` that + /// Phase D will call once the wire stage cuts over. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + + /// Per-key response-shape overrides — see issue #302 §5 + /// `RuntimeConfig.response`. `None` until cp-api ships the block. + /// Same Phase D wiring story as [`Self::request`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + /// Filled in by the snapshot loader from the etcd key path. #[serde(skip)] pub(crate) runtime_id: String, @@ -111,6 +133,133 @@ pub struct TelemetryTags { pub byo_label: Option, } +/// Per-`ProviderKey` request-shape overrides — see issue #302 §5 +/// `RuntimeConfig.request`. Each field maps 1:1 onto a primitive +/// apply function in [`aisix-provider-openai`'s `overrides` +/// module](https://github.com/api7/ai-gateway/blob/main/crates/aisix-provider-openai/src/overrides.rs): +/// +/// - `param_renames` → `apply_param_renames` +/// - `param_constraints` → `apply_param_constraints` +/// - `default_headers` → `apply_default_headers` +/// - `default_body_fields` → `apply_default_body_fields` +/// +/// `f64` in [`ParamConstraints`] is the reason the parent +/// [`ProviderKey`] derives `PartialEq` rather than `Eq`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RequestOverrides { + /// `apply_param_renames` input. Top-level body keys named on the + /// left are renamed to the right. Empty map is the default. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub param_renames: HashMap, + + /// `apply_param_constraints` input. `None` means no clamping. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub param_constraints: Option, + + /// `apply_default_headers` input. Top-level headers added to the + /// outbound request when the caller did not set them. Reserved + /// auth headers are dropped by `apply_default_headers` as + /// defense-in-depth. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub default_headers: HashMap, + + /// `apply_default_body_fields` input. Top-level body fields added + /// when the caller did not set them. `serde_json::Map` preserves + /// insertion order on serialize, matching the etcd round-trip. + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub default_body_fields: Map, +} + +/// Numeric range clamps applied to chat-completion request bodies — +/// the on-disk shape of issue #302 §5 `param_constraints`. Phase A +/// scope is `temperature` only; `top_p` / `frequency_penalty` are +/// deferred until a real upstream quirk demands them (YAGNI per +/// `CLAUDE.md` §2). +/// +/// `f64` not `Eq`: NaN comparisons make a derived `Eq` unsound. +/// [`PartialEq`] is enough for the round-trip test. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ParamConstraints { + /// Upper bound for `temperature`. Values above this are clamped + /// to this value. `None` means "no upper clamp". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature_max: Option, + + /// Lower bound for `temperature`. Values below this are clamped + /// to this value. `None` means "no lower clamp". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature_min: Option, +} + +/// Per-`ProviderKey` response-shape overrides — see issue #302 §5 +/// `RuntimeConfig.response`. Each field maps onto behavior the +/// [`aisix-provider-openai`'s `overrides` +/// module](https://github.com/api7/ai-gateway/blob/main/crates/aisix-provider-openai/src/overrides.rs) +/// already implements: +/// +/// - `stream_done_marker` → `apply_stream_done_marker_policy` +/// - `content_list_to_string` → `apply_content_list_to_string` +/// (applied to the *request* body before send when the upstream +/// only accepts string content) +/// - `reasoning_field` → `extract_reasoning_field` +/// +/// `error_envelope` is on-disk only — issue #302 §5 keeps it as a +/// `"openai" | "passthrough"` string so cp-api can iterate without +/// a Rust-side enum migration. Phase D pins the closed set. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ResponseOverrides { + /// Stream `[DONE]` terminator expectation. `None` means "no + /// opinion" — same effect as [`StreamDoneMarker::Optional`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_done_marker: Option, + + /// When `true`, the request-body `messages[*].content` array of + /// text blocks gets flattened to a single string before dispatch. + /// Defaults to `false` (no flattening). + #[serde(default)] + pub content_list_to_string: bool, + + /// On-disk discriminator for the error-translation strategy. + /// `"openai"` projects upstream errors into the OpenAI envelope; + /// `"passthrough"` returns the upstream body as-is. Open string + /// in this PR (issue #302 §5 wire shape); Phase D pins the + /// closed set in a follow-up. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_envelope: Option, + + /// `extract_reasoning_field` path. Empty / `None` means no lift. + /// Example: `"delta.reasoning_content"` (DeepSeek's canonical + /// shape, already aligned with the gateway's emit slot). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_field: Option, +} + +/// Stream `[DONE]` terminator policy for an SSE response — the +/// on-disk shape of issue #302 §5 `stream_done_marker`. The wire +/// form is the lowercased variant name (`"required"` / `"optional"` +/// / `"none"`) so cp-api JSON keeps the same set the original spec +/// drafted. +/// +/// The runtime apply function lives in `aisix-provider-openai` +/// (`apply_stream_done_marker_policy`) and consumes this enum +/// directly via re-export from `aisix-core`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StreamDoneMarker { + /// Upstream must emit `data: [DONE]`. Absence is a wire-shape + /// violation. OpenAI proper, DeepSeek, Groq. + Required, + /// Either presence or absence is acceptable. Used when the + /// upstream is OpenAI-compat but does not promise the terminator. + Optional, + /// Upstream is expected to *omit* the marker. Some Azure / Vertex + /// flavors terminate cleanly on connection close. + None, +} + impl Resource for ProviderKey { fn id(&self) -> &str { &self.runtime_id @@ -262,8 +411,9 @@ mod tests { fn round_trip_omits_default_phase_a_fields() { // A ProviderKey built without setting the Phase A fields // serializes with `provider:""` and `telemetry_tags` defaulted, - // and `adapter` absent (skipped because None). Re-deserializing - // must reproduce the original struct. + // and `adapter` / `request` / `response` absent (skipped + // because None). Re-deserializing must reproduce the original + // struct. let original = ProviderKey { display_name: "openai-prod".into(), secret: "sk-x".into(), @@ -271,10 +421,179 @@ mod tests { provider: String::new(), adapter: None, telemetry_tags: TelemetryTags::default(), + request: None, + response: None, runtime_id: String::new(), }; let s = serde_json::to_string(&original).unwrap(); let back: ProviderKey = serde_json::from_str(&s).unwrap(); assert_eq!(original, back); } + + // ---- issue #302 Phase A2.5: ProviderKey.request / .response ---- + + #[test] + fn legacy_payload_without_request_response_blocks_deserialises_to_none() { + // Backward-compat: an existing on-disk payload that pre-dates + // the Phase A2.5 PR must still deserialize, and `request` / + // `response` must land at `None`. + let p: ProviderKey = + serde_json::from_str(r#"{"display_name":"openai-prod","secret":"sk-x"}"#).unwrap(); + assert!(p.request.is_none()); + assert!(p.response.is_none()); + } + + #[test] + fn request_overrides_empty_object_deserialises_to_defaults() { + // `{"request": {}}` must succeed and yield an all-default + // RequestOverrides — empty maps, no constraints. + let p: ProviderKey = + serde_json::from_str(r#"{"display_name":"x","secret":"k","request":{}}"#).unwrap(); + let req = p.request.expect("request was Some"); + assert!(req.param_renames.is_empty()); + assert!(req.param_constraints.is_none()); + assert!(req.default_headers.is_empty()); + assert!(req.default_body_fields.is_empty()); + } + + #[test] + fn request_overrides_full_payload_deserialises() { + // Mirror the on-disk example in issue #302 §5 exactly. + let p: ProviderKey = serde_json::from_str( + r#"{ + "display_name": "deepseek-prod", + "secret": "sk-x", + "request": { + "param_renames": { "max_completion_tokens": "max_tokens" }, + "param_constraints": { "temperature_max": 1.0 }, + "default_headers": { "X-Foo": "bar" }, + "default_body_fields": { "safe_prompt": true } + } + }"#, + ) + .unwrap(); + let req = p.request.expect("request was Some"); + assert_eq!( + req.param_renames.get("max_completion_tokens"), + Some(&"max_tokens".to_string()) + ); + let constraints = req.param_constraints.expect("param_constraints was Some"); + assert_eq!(constraints.temperature_max, Some(1.0)); + assert_eq!(constraints.temperature_min, None); + assert_eq!(req.default_headers.get("X-Foo"), Some(&"bar".to_string())); + assert_eq!( + req.default_body_fields.get("safe_prompt"), + Some(&serde_json::Value::Bool(true)) + ); + } + + #[test] + fn request_overrides_rejects_unknown_field() { + // deny_unknown_fields on RequestOverrides stops a typo in + // cp-api JSON from silently no-oping the apply call. + let r: Result = serde_json::from_str( + r#"{ + "display_name": "x", + "secret": "k", + "request": { "param_rename": {} } + }"#, + ); + assert!(r.is_err()); + } + + #[test] + fn response_overrides_empty_object_deserialises_to_defaults() { + let p: ProviderKey = + serde_json::from_str(r#"{"display_name":"x","secret":"k","response":{}}"#).unwrap(); + let resp = p.response.expect("response was Some"); + assert!(resp.stream_done_marker.is_none()); + assert!(!resp.content_list_to_string); + assert!(resp.error_envelope.is_none()); + assert!(resp.reasoning_field.is_none()); + } + + #[test] + fn response_overrides_full_payload_deserialises() { + // Mirror the on-disk example in issue #302 §5 exactly. + let p: ProviderKey = serde_json::from_str( + r#"{ + "display_name": "deepseek-prod", + "secret": "sk-x", + "response": { + "stream_done_marker": "required", + "content_list_to_string": false, + "error_envelope": "openai", + "reasoning_field": "delta.reasoning_content" + } + }"#, + ) + .unwrap(); + let resp = p.response.expect("response was Some"); + assert_eq!(resp.stream_done_marker, Some(StreamDoneMarker::Required)); + assert!(!resp.content_list_to_string); + assert_eq!(resp.error_envelope.as_deref(), Some("openai")); + assert_eq!( + resp.reasoning_field.as_deref(), + Some("delta.reasoning_content") + ); + } + + #[test] + fn response_overrides_rejects_unknown_field() { + let r: Result = serde_json::from_str( + r#"{ + "display_name": "x", + "secret": "k", + "response": { "reasoning_fields": "delta.foo" } + }"#, + ); + assert!(r.is_err()); + } + + #[test] + fn stream_done_marker_deserialises_all_three_variants() { + // The on-disk wire form is the lowercased variant — verify + // every literal the cp-api spec promises. + for (raw, expected) in [ + ("required", StreamDoneMarker::Required), + ("optional", StreamDoneMarker::Optional), + ("none", StreamDoneMarker::None), + ] { + let resp: ResponseOverrides = + serde_json::from_str(&format!(r#"{{"stream_done_marker":"{raw}"}}"#)).unwrap(); + assert_eq!(resp.stream_done_marker, Some(expected)); + } + } + + #[test] + fn stream_done_marker_rejects_unknown_variant() { + // Closed enum — uppercase or unknown variants must fail loudly. + let r: Result = + serde_json::from_str(r#"{"stream_done_marker":"Required"}"#); + assert!(r.is_err()); + + let r: Result = + serde_json::from_str(r#"{"stream_done_marker":"maybe"}"#); + assert!(r.is_err()); + } + + #[test] + fn param_constraints_round_trips() { + // Both clamps set → both come back identical after a + // JSON round-trip. f64 equality holds for finite values. + let original = ParamConstraints { + temperature_max: Some(1.0), + temperature_min: Some(0.0), + }; + let s = serde_json::to_string(&original).unwrap(); + let back: ParamConstraints = serde_json::from_str(&s).unwrap(); + assert_eq!(back.temperature_max, Some(1.0)); + assert_eq!(back.temperature_min, Some(0.0)); + } + + #[test] + fn param_constraints_rejects_unknown_field() { + let r: Result = serde_json::from_str(r#"{"top_p_max": 0.9}"#); + assert!(r.is_err()); + } } diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index de043bf0..921acaed 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -276,10 +276,13 @@ fn apikey_schema() -> Value { fn provider_key_schema() -> Value { // `provider`, `adapter`, and `telemetry_tags` were added as a - // skeleton for issue #302 Phase A. They are optional on the wire - // (matching `#[serde(default)]` on the Rust side) so existing - // ProviderKey payloads without these fields keep validating. No - // dispatch path reads them in this PR. + // skeleton for issue #302 Phase A (PR #298). `request` and + // `response` were added in Phase A2.5 to land the on-disk shape + // for the `RuntimeConfig.request` / `RuntimeConfig.response` + // blocks from issue #302 §5. All Phase A fields are optional on + // the wire (matching `#[serde(default)]` on the Rust side) so + // existing ProviderKey payloads without these fields keep + // validating. No dispatch path reads them in this PR. json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -306,6 +309,53 @@ fn provider_key_schema() -> Value { "pk_label": { "type": ["string", "null"] }, "byo_label": { "type": ["string", "null"] } } + }, + // Phase A2.5 — RuntimeConfig.request, see issue #302 §5. + // Each sub-field is the input to a primitive apply + // function in aisix-provider-openai's overrides module. + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "param_renames": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "param_constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "temperature_max": { "type": "number" }, + "temperature_min": { "type": "number" } + } + }, + "default_headers": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + // Free-form on purpose — the cp-api spec lets + // operators set any default top-level body field + // (`safe_prompt`, `transforms`, etc.); the apply + // path only adds keys when the caller did not + // set them. + "default_body_fields": { + "type": "object" + } + } + }, + // Phase A2.5 — RuntimeConfig.response, see issue #302 §5. + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "stream_done_marker": { "type": "string", "enum": ["required", "optional", "none"] }, + "content_list_to_string": { "type": "boolean" }, + // Open string in Phase A2.5 — matches the Rust + // `Option`. Phase D pins the closed + // ("openai" | "passthrough") set. + "error_envelope": { "type": "string" }, + "reasoning_field": { "type": "string" } + } } } }) @@ -1069,4 +1119,95 @@ mod tests { }); assert!(validate_provider_key(&v).is_err()); } + + // ---- provider_key schema (issue #302 Phase A2.5 — request/response) ---- + + #[test] + fn provider_key_with_request_block_passes() { + // Mirror the on-disk example in issue #302 §5 exactly. + let v = json!({ + "display_name": "deepseek-prod", + "secret": "sk-x", + "request": { + "param_renames": { "max_completion_tokens": "max_tokens" }, + "param_constraints": { "temperature_max": 1.0 }, + "default_headers": { "X-Foo": "bar" }, + "default_body_fields": { "safe_prompt": true } + } + }); + validate_provider_key(&v).unwrap(); + } + + #[test] + fn provider_key_with_response_block_passes() { + let v = json!({ + "display_name": "deepseek-prod", + "secret": "sk-x", + "response": { + "stream_done_marker": "required", + "content_list_to_string": false, + "error_envelope": "openai", + "reasoning_field": "delta.reasoning_content" + } + }); + validate_provider_key(&v).unwrap(); + } + + #[test] + fn provider_key_with_empty_request_response_blocks_passes() { + // `{}` for each block must validate — matches the Rust-side + // all-default deserialization path. + let v = json!({ + "display_name": "x", + "secret": "k", + "request": {}, + "response": {} + }); + validate_provider_key(&v).unwrap(); + } + + #[test] + fn provider_key_request_rejects_unknown_field() { + let v = json!({ + "display_name": "x", + "secret": "k", + "request": { "param_rename": {} } + }); + assert!(validate_provider_key(&v).is_err()); + } + + #[test] + fn provider_key_response_rejects_unknown_field() { + let v = json!({ + "display_name": "x", + "secret": "k", + "response": { "reasoning_fields": "delta.foo" } + }); + assert!(validate_provider_key(&v).is_err()); + } + + #[test] + fn provider_key_response_rejects_unknown_stream_done_marker() { + let v = json!({ + "display_name": "x", + "secret": "k", + "response": { "stream_done_marker": "maybe" } + }); + assert!(validate_provider_key(&v).is_err()); + } + + #[test] + fn provider_key_request_param_constraints_rejects_unknown_field() { + // `param_constraints` is closed (`additionalProperties: false`) + // so a stray `top_p_max` from a future schema iteration can't + // sneak past today's DP. + let v = json!({ + "display_name": "x", + "secret": "k", + "request": { + "param_constraints": { "top_p_max": 0.9 } + } + }); + assert!(validate_provider_key(&v).is_err()); + } } diff --git a/crates/aisix-provider-openai/src/overrides.rs b/crates/aisix-provider-openai/src/overrides.rs index 4c91ded2..ccf1956b 100644 --- a/crates/aisix-provider-openai/src/overrides.rs +++ b/crates/aisix-provider-openai/src/overrides.rs @@ -8,19 +8,19 @@ //! cp-api can capture per-provider quirks without forking a Bridge. //! //! This module ships the primitive transforms. **Nothing in -//! [`OpenAiBridge`](crate::OpenAiBridge) wires them in yet** — the -//! struct types that would carry the override blocks on `ProviderKey` -//! are not landed (#298 added `provider` / `adapter` / `telemetry_tags` -//! only). Phase D consumes these functions from inside the Bridge -//! when the new contract cuts over. Until then the public API is -//! exercised by unit tests against `serde_json::Value` / -//! `http::HeaderMap` inputs. +//! [`OpenAiBridge`](crate::OpenAiBridge) wires them in yet** — Phase +//! A2.5 added the on-disk shape (`RequestOverrides` / `ResponseOverrides` +//! on [`aisix_core::ProviderKey`]); Phase D consumes the functions +//! here from inside the Bridge once the new contract cuts over. Until +//! then the public API is exercised by unit tests against +//! `serde_json::Value` / `http::HeaderMap` inputs. //! -//! Each function takes primitive Rust types rather than a future -//! `RequestOverrides` / `ResponseOverrides` struct on purpose — the -//! caller picks fields out of whatever container lands and forwards -//! them here, so the wire schema for those blocks can iterate without -//! touching this file. +//! The closed schema types ([`ParamConstraints`], [`StreamDoneMarker`]) +//! live in `aisix-core` so cp-api can write them straight into etcd +//! payloads; this module re-uses those types for its apply-function +//! signatures so cp-api and the DP agree on a single wire shape. +//! [`StreamDoneOutcome`] is purely a runtime evaluation result and +//! stays here — it never serializes. //! //! Reference implementations consulted: //! - LiteLLM `convert_content_list_to_str` — @@ -34,48 +34,16 @@ use std::collections::HashMap; +use aisix_core::{ParamConstraints, StreamDoneMarker}; use http::{ header::{HeaderName, HeaderValue}, HeaderMap, }; use serde_json::{Map, Value}; -/// Numeric range clamps applied to chat-completion request bodies. -/// -/// Mirrors the `param_constraints` block in issue #302 §5. Phase A -/// scope: `temperature` only — `top_p`, `frequency_penalty`, and -/// friends are intentionally deferred until a real upstream quirk -/// requires them (YAGNI per CLAUDE.md §2). -#[derive(Debug, Default, Clone)] -pub struct Constraints { - /// Upper bound for `temperature`. Values above this are clamped - /// to this value. `None` means "no upper clamp". - pub temperature_max: Option, - /// Lower bound for `temperature`. Values below this are clamped - /// to this value. `None` means "no lower clamp". - pub temperature_min: Option, -} - -/// Stream `[DONE]` terminator policy for an SSE response. -/// -/// Mirrors `response.stream_done_marker` in issue #302 §5. The -/// caller observes whether the upstream actually emitted -/// `data: [DONE]` and dispatches into [`apply_stream_done_marker_policy`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamDoneMarker { - /// Upstream must emit `data: [DONE]`. Absence is a wire-shape - /// violation. OpenAI proper, DeepSeek, Groq. - Required, - /// Either presence or absence is acceptable. Used when the - /// upstream is OpenAI-compat but does not promise the terminator. - Optional, - /// Upstream is expected to *omit* the marker. Some Azure / Vertex - /// flavors terminate cleanly on connection close. - None, -} - /// Outcome of evaluating an SSE stream against a -/// [`StreamDoneMarker`] policy. +/// [`StreamDoneMarker`] policy. Runtime-only — never serialized to +/// etcd, so it stays in the provider crate rather than `aisix-core`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamDoneOutcome { /// Stream complied with the policy. @@ -124,7 +92,7 @@ pub fn apply_param_renames(body: &mut Value, renames: &HashMap) /// added when a real upstream needs them. If the body has no /// `temperature` field, or its value is not a number, the function /// is a no-op (the upstream itself will surface invalid types). -pub fn apply_param_constraints(body: &mut Value, constraints: &Constraints) { +pub fn apply_param_constraints(body: &mut Value, constraints: &ParamConstraints) { let Some(obj) = body.as_object_mut() else { return; }; @@ -465,7 +433,7 @@ mod tests { #[test] fn temperature_above_max_is_clamped() { let mut body = json!({ "temperature": 1.7 }); - let constraints = Constraints { + let constraints = ParamConstraints { temperature_max: Some(1.0), temperature_min: None, }; @@ -476,7 +444,7 @@ mod tests { #[test] fn temperature_within_range_is_untouched() { let mut body = json!({ "temperature": 0.7 }); - let constraints = Constraints { + let constraints = ParamConstraints { temperature_max: Some(1.0), temperature_min: Some(0.0), }; @@ -487,7 +455,7 @@ mod tests { #[test] fn temperature_below_min_is_clamped() { let mut body = json!({ "temperature": -0.2 }); - let constraints = Constraints { + let constraints = ParamConstraints { temperature_max: None, temperature_min: Some(0.0), }; @@ -498,7 +466,7 @@ mod tests { #[test] fn temperature_missing_is_noop() { let mut body = json!({ "model": "gpt-4o" }); - let constraints = Constraints { + let constraints = ParamConstraints { temperature_max: Some(1.0), temperature_min: None, }; @@ -511,7 +479,7 @@ mod tests { // Garbage-typed temperature lets the upstream surface the // type error; the clamp doesn't try to coerce. let mut body = json!({ "temperature": "hot" }); - let constraints = Constraints { + let constraints = ParamConstraints { temperature_max: Some(1.0), temperature_min: None, }; @@ -522,7 +490,7 @@ mod tests { #[test] fn empty_constraints_is_noop() { let mut body = json!({ "temperature": 2.0 }); - let constraints = Constraints::default(); + let constraints = ParamConstraints::default(); apply_param_constraints(&mut body, &constraints); assert_eq!(body["temperature"].as_f64(), Some(2.0)); }