[Router] Fleet-wide sampling contract 1/3: the config surface (#39000)
Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kangyan Zhou
Claude Opus 5
parent
4678536df6
commit
5f17e3a75f
@@ -9,6 +9,7 @@ use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use crate::config::sampling::{parse_sampling_overrides, ConflictPolicy, SamplingOverrides};
|
||||
use crate::config::{
|
||||
default_cb_cool_down, default_proxy_request_timeout_secs, default_stale_request_timeout_secs,
|
||||
resolve_mode, ActiveLoadConfig, AffinityConfig, AffinityMode, CacheAwareConfig,
|
||||
@@ -60,6 +61,33 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub bucket_config: Option<String>,
|
||||
|
||||
// ---- fleet-wide sampling contract (opt-in) ----
|
||||
/// Sampling parameters fixed fleet-wide, as one JSON object keyed by the
|
||||
/// request-body field names — e.g. `{"temperature": 1, "top_p": 0.95}`.
|
||||
/// Keys: temperature, top_p, top_k, min_p, repetition_penalty,
|
||||
/// frequency_penalty, presence_penalty, n. Each value is a number, or an
|
||||
/// inclusive band `{"min": LO, "max": HI}`.
|
||||
///
|
||||
/// A configured value is injected whenever the request omits that field;
|
||||
/// `--sampling-param-conflict` decides what a request that sends one gets.
|
||||
/// Unknown or repeated keys, out-of-domain values and a band under `allow`
|
||||
/// all fail the launch, naming the offending key. Full contract — domains,
|
||||
/// `null` handling, cost — in the router README.
|
||||
#[arg(long, value_name = "JSON")]
|
||||
pub override_sampling_params: Option<String>,
|
||||
/// What a request that sends a value differing from
|
||||
/// `--override-sampling-params` gets: `reject` (the default) 400s it
|
||||
/// before admission, quoting the configured value; `allow` forwards the
|
||||
/// client's value untouched. Only accepted alongside
|
||||
/// `--override-sampling-params`.
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
value_name = "MODE",
|
||||
requires = "override_sampling_params"
|
||||
)]
|
||||
pub sampling_param_conflict: Option<ConflictPolicy>,
|
||||
|
||||
// ---- circuit breaker (opt-in via --cb-threshold) ----
|
||||
/// Consecutive upstream failures before the circuit breaker opens.
|
||||
/// Setting this enables the circuit breaker; `0` is rejected.
|
||||
@@ -577,6 +605,13 @@ impl Cli {
|
||||
None
|
||||
};
|
||||
|
||||
let sampling_overrides = match &self.override_sampling_params {
|
||||
None => SamplingOverrides::default(),
|
||||
Some(raw) => {
|
||||
parse_sampling_overrides(raw, self.sampling_param_conflict.unwrap_or_default())?
|
||||
}
|
||||
};
|
||||
|
||||
let config = Config {
|
||||
server: ServerConfig {
|
||||
host: self.host,
|
||||
@@ -600,6 +635,7 @@ impl Cli {
|
||||
affinity,
|
||||
fused,
|
||||
eligibility,
|
||||
sampling_overrides,
|
||||
},
|
||||
discovery,
|
||||
proxy: ProxyConfig {
|
||||
@@ -1885,4 +1921,64 @@ mod tests {
|
||||
crate::config::SloBucketPolicy::BestEffort
|
||||
);
|
||||
}
|
||||
|
||||
/// The flag reaches `ModelConfig`, and is opt-in: unset leaves the model
|
||||
/// with an empty sampling contract, so no request is ever checked.
|
||||
#[test]
|
||||
fn override_sampling_params_reaches_the_model_config() {
|
||||
let c = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--override-sampling-params",
|
||||
r#"{"temperature": 1, "top_p": 0.95}"#,
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(c.model.sampling_overrides.params.len(), 2);
|
||||
// `reject` is the default mode: declaring a contract is the usual
|
||||
// reason to declare one.
|
||||
assert_eq!(c.model.sampling_overrides.conflict, ConflictPolicy::Reject);
|
||||
|
||||
let c = into_config_owned(with_model(&["--worker-urls", "http://x:30000"])).unwrap();
|
||||
assert!(c.model.sampling_overrides.params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_param_conflict_selects_the_mode() {
|
||||
let c = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--override-sampling-params",
|
||||
r#"{"temperature": 1}"#,
|
||||
"--sampling-param-conflict",
|
||||
"allow",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(c.model.sampling_overrides.conflict, ConflictPolicy::Allow);
|
||||
|
||||
// The mode alone governs nothing, so clap rejects it (`requires`).
|
||||
let err = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--sampling-param-conflict",
|
||||
"reject",
|
||||
]))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("--override-sampling-params"), "got: {err}");
|
||||
}
|
||||
|
||||
/// A malformed contract fails the launch with the parser's own message,
|
||||
/// rather than starting a router that 400s every request at the engine.
|
||||
#[test]
|
||||
fn malformed_override_sampling_params_fails_the_launch() {
|
||||
let err = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--override-sampling-params",
|
||||
r#"{"temp": 1}"#,
|
||||
]))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("unknown parameter"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod cli;
|
||||
pub mod sampling;
|
||||
pub mod types;
|
||||
pub use cli::Cli;
|
||||
pub use sampling::*;
|
||||
pub use types::*;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -18,6 +20,7 @@ impl Config {
|
||||
if let Some(bucket_config) = self.model.bucket_config.as_ref() {
|
||||
validate_bucket_config(bucket_config)?;
|
||||
}
|
||||
self.model.sampling_overrides.validate()?;
|
||||
match &self.discovery {
|
||||
DiscoveryBackend::StaticUrls(s) => {
|
||||
if s.urls.is_empty() {
|
||||
@@ -223,6 +226,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: urls.iter().map(|s| s.to_string()).collect(),
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Fleet-wide sampling contract (`--override-sampling-params` /
|
||||
//! `--sampling-param-conflict`): the parameters an operator fixes for every
|
||||
//! request this router serves, and what a request that disagrees gets.
|
||||
//!
|
||||
//! WHY this is parsed by hand rather than with `serde(deny_unknown_fields)`:
|
||||
//! the flag is read once, at startup, on a router that crash-loops if it is
|
||||
//! wrong, so the message an operator reads out of `kubectl logs` is the whole
|
||||
//! debugging session. Every rejection here names the offending key, the value
|
||||
//! it saw, and the domain it violated. The same reasoning is why both the
|
||||
//! outer object and a band are decoded as ordered ENTRIES instead of a
|
||||
//! `serde_json::Map`: a map keeps only the last of a repeated key, so
|
||||
//! `{"temperature": 0, "temperature": 1}` would start cleanly and enforce a
|
||||
//! value the operator did not write.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Sampling parameters fixed fleet-wide, and what to do with a request that
|
||||
/// disagrees.
|
||||
///
|
||||
/// A configured parameter is always injected into the forwarded body when the
|
||||
/// request OMITS it, so the engine's own defaults cannot drift from what the
|
||||
/// operator declared. What differs between the two [`ConflictPolicy`] modes is
|
||||
/// only the request that DOES send the field: `Reject` makes the value an
|
||||
/// immutability contract (400 before admission — never a silent rewrite),
|
||||
/// while `Allow` lets the client value through untouched, degrading the
|
||||
/// configured value to a fleet-wide default.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SamplingOverrides {
|
||||
/// Configured parameters, keyed so enforcement and injection are one loop
|
||||
/// over whatever the operator set instead of a per-field ladder repeated
|
||||
/// at each site. Iterating a `BTreeMap` keyed by the field enum is what
|
||||
/// fixes the order values are injected in, so a forwarded body is
|
||||
/// byte-identical across runs.
|
||||
pub params: BTreeMap<SamplingField, ParamSpec>,
|
||||
/// Applies to every configured parameter: there is deliberately no
|
||||
/// per-parameter mode, so an operator reads one knob off one manifest.
|
||||
pub conflict: ConflictPolicy,
|
||||
}
|
||||
|
||||
impl SamplingOverrides {
|
||||
/// Re-check every invariant [`parse_sampling_overrides`] enforces, on an
|
||||
/// already-built value.
|
||||
///
|
||||
/// WHY this is separate from the parser: the parser turns a raw JSON
|
||||
/// string into this struct and is reachable only from the CLI, but the
|
||||
/// struct itself is reachable from anywhere — a test fixture, a future
|
||||
/// config file, an admin API. [`crate::config::Config::validate`] calls
|
||||
/// this so no such path can hold a spec the flag would have refused to
|
||||
/// start with (an out-of-domain exact value, an inverted band whose
|
||||
/// `contains` rejects every value, or a band under `allow`, which names
|
||||
/// nothing to inject and rejects nothing).
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
for (&field, spec) in &self.params {
|
||||
validate_spec(field, spec, self.conflict)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a request sending a value that differs from the configured one gets
|
||||
/// (`--sampling-param-conflict`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
||||
pub enum ConflictPolicy {
|
||||
/// 400 before admission, quoting the configured value. The default: the
|
||||
/// point of declaring a fleet-wide sampling contract is usually that it
|
||||
/// holds, and silently serving something other than what the client asked
|
||||
/// for is the one behavior no client can detect.
|
||||
#[default]
|
||||
Reject,
|
||||
/// Forward the client's value to the engine untouched. The configured
|
||||
/// value degrades to a fill-when-absent default.
|
||||
Allow,
|
||||
}
|
||||
|
||||
/// One configured parameter's value: a single value, or an inclusive band of
|
||||
/// accepted ones.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ParamSpec {
|
||||
/// A single value: injected when the request omits the field, and under
|
||||
/// [`ConflictPolicy::Reject`] the only value a request may send.
|
||||
///
|
||||
/// Held as the parsed JSON number rather than an `f64` so injection
|
||||
/// re-emits the operator's literal — `"n": 1` stays `1` and does not
|
||||
/// become `1.0` on the wire for the integer-typed fields.
|
||||
Exact(serde_json::Number),
|
||||
/// An inclusive `[lo, hi]` band of accepted values, for a contract that
|
||||
/// fixes most sampling knobs but leaves one tunable inside a range. A band
|
||||
/// names no single value, so it never injects; it only rejects
|
||||
/// out-of-band values, which is why a band under [`ConflictPolicy::Allow`]
|
||||
/// is a startup error rather than a no-op.
|
||||
///
|
||||
/// A band therefore constrains only the requests that NAME the parameter.
|
||||
/// A request that omits it gets the model's own default (the engine reads
|
||||
/// `generation_config`), which the router cannot see and which may itself
|
||||
/// lie outside the band. An operator who needs the omitting majority
|
||||
/// pinned too wants an exact value, not a band.
|
||||
Range { lo: f64, hi: f64 },
|
||||
}
|
||||
|
||||
/// A sampling parameter that can be fixed fleet-wide. The enum is what makes a
|
||||
/// typo in the `--override-sampling-params` JSON a startup error instead of a
|
||||
/// key that silently never matches a request field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SamplingField {
|
||||
Temperature,
|
||||
TopP,
|
||||
TopK,
|
||||
MinP,
|
||||
RepetitionPenalty,
|
||||
FrequencyPenalty,
|
||||
PresencePenalty,
|
||||
N,
|
||||
}
|
||||
|
||||
impl SamplingField {
|
||||
/// Every field, in the order they are written into the forwarded body.
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::Temperature,
|
||||
Self::TopP,
|
||||
Self::TopK,
|
||||
Self::MinP,
|
||||
Self::RepetitionPenalty,
|
||||
Self::FrequencyPenalty,
|
||||
Self::PresencePenalty,
|
||||
Self::N,
|
||||
];
|
||||
|
||||
/// This field's slot in [`Self::ALL`], and in the request probe's
|
||||
/// fixed-size array of probed values.
|
||||
///
|
||||
/// Declaration order IS the slot order, so this cannot assign a wrong
|
||||
/// one. What still needs checking is that [`Self::ALL`] agrees — see the
|
||||
/// assertion below; `from_wire_name` and `supported_fields` both iterate
|
||||
/// `ALL`, so a field missing from it is rejected at startup as an unknown
|
||||
/// key, the failure that is invisible to any test that also iterates
|
||||
/// `ALL`.
|
||||
pub const fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
/// The request-body key, identical on the wire in and out: the JSON config
|
||||
/// names the parameter exactly as a client sends it.
|
||||
pub const fn wire_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temperature => "temperature",
|
||||
Self::TopP => "top_p",
|
||||
Self::TopK => "top_k",
|
||||
Self::MinP => "min_p",
|
||||
Self::RepetitionPenalty => "repetition_penalty",
|
||||
Self::FrequencyPenalty => "frequency_penalty",
|
||||
Self::PresencePenalty => "presence_penalty",
|
||||
Self::N => "n",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a key from the `--override-sampling-params` JSON object.
|
||||
pub fn from_wire_name(key: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|f| f.wire_name() == key)
|
||||
}
|
||||
|
||||
/// True for the fields the engine types as `int`, which a configured value
|
||||
/// must therefore be integral for.
|
||||
pub const fn is_integral(self) -> bool {
|
||||
matches!(self, Self::TopK | Self::N)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`SamplingField::ALL`] agrees with [`SamplingField::index`] — see that
|
||||
/// method for why a disagreement is a silent startup rejection.
|
||||
const _: () = {
|
||||
let mut i = 0;
|
||||
while i < SamplingField::ALL.len() {
|
||||
assert!(SamplingField::ALL[i].index() == i);
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse `--override-sampling-params` into a [`SamplingOverrides`].
|
||||
pub(crate) fn parse_sampling_overrides(
|
||||
raw: &str,
|
||||
conflict: ConflictPolicy,
|
||||
) -> Result<SamplingOverrides> {
|
||||
let ObjectEntries(entries) = serde_json::from_str(raw).map_err(|e| {
|
||||
anyhow!(
|
||||
"--override-sampling-params must be a JSON object like \
|
||||
'{{\"temperature\": 1, \"top_p\": 0.95}}': {e}"
|
||||
)
|
||||
})?;
|
||||
if entries.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params is empty: pass at least one of {}, or omit the flag",
|
||||
supported_fields()
|
||||
));
|
||||
}
|
||||
let mut params = BTreeMap::new();
|
||||
for (key, value) in entries {
|
||||
let field = SamplingField::from_wire_name(&key).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"--override-sampling-params: unknown parameter \"{key}\" (supported: {})",
|
||||
supported_fields()
|
||||
)
|
||||
})?;
|
||||
let spec = match value {
|
||||
ParamValue::Number(n) => {
|
||||
ParamSpec::Exact(canonical_number(field, checked_value(field, &n)?, n))
|
||||
}
|
||||
ParamValue::Band(entries) => parse_band(field, entries)?,
|
||||
ParamValue::Other(other) => {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} must be a number or a \
|
||||
{{\"min\": LO, \"max\": HI}} band, got {other}"
|
||||
))
|
||||
}
|
||||
};
|
||||
if params.insert(field, spec).is_some() {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {} is set more than once",
|
||||
field.wire_name()
|
||||
));
|
||||
}
|
||||
}
|
||||
let overrides = SamplingOverrides { params, conflict };
|
||||
// Re-checks the domains `checked_value` already covered above. That first
|
||||
// pass is not redundant: it is what quotes the operator's own literal
|
||||
// (`1e2`, not `100`) and what guards `canonical_number`'s saturating
|
||||
// `as i64` cast before it runs. This pass is what a hand-built
|
||||
// `SamplingOverrides` gets, and is the only check a band's bounds see.
|
||||
overrides.validate()?;
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
/// Check one already-built spec. Shared by [`parse_sampling_overrides`] and
|
||||
/// [`SamplingOverrides::validate`] so a hand-built `SamplingOverrides` is held
|
||||
/// to exactly the domain the flag is.
|
||||
fn validate_spec(field: SamplingField, spec: &ParamSpec, conflict: ConflictPolicy) -> Result<()> {
|
||||
let key = field.wire_name();
|
||||
match spec {
|
||||
ParamSpec::Exact(n) => {
|
||||
checked_value(field, n)?;
|
||||
}
|
||||
&ParamSpec::Range { lo, hi } => {
|
||||
check_domain(field, lo, &lo.to_string())?;
|
||||
check_domain(field, hi, &hi.to_string())?;
|
||||
if lo > hi {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} band needs min <= max, got min {lo} > max {hi}"
|
||||
));
|
||||
}
|
||||
// Bounds are checked one at a time, which is only sufficient for a
|
||||
// contiguous domain. `top_k`'s is not ({-1} U [1, inf)): `{"min": -1,
|
||||
// "max": 100}` has two individually legal bounds and would admit
|
||||
// `top_k: 0`, which is rejected as an exact value. -1 is a sentinel,
|
||||
// not a range endpoint.
|
||||
if field == SamplingField::TopK && lo < 1.0 {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: top_k band bounds must both be >= 1 \
|
||||
(-1 disables top_k entirely and cannot bound a range)"
|
||||
));
|
||||
}
|
||||
// A band only ever rejects, so under `allow` it would be dead config
|
||||
// that silently accepts everything.
|
||||
if conflict == ConflictPolicy::Allow {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: the {key} band requires \
|
||||
--sampling-param-conflict reject — under `allow` nothing is rejected \
|
||||
and a band names no value to inject"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn one parameter's `{"min": LO, "max": HI}` entries into a
|
||||
/// [`ParamSpec::Range`].
|
||||
fn parse_band(
|
||||
field: SamplingField,
|
||||
entries: Vec<(String, serde_json::Value)>,
|
||||
) -> Result<ParamSpec> {
|
||||
let key = field.wire_name();
|
||||
let (mut min, mut max) = (None, None);
|
||||
for (bound, v) in entries {
|
||||
let slot = match bound.as_str() {
|
||||
"min" => &mut min,
|
||||
"max" => &mut max,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} band must be exactly \
|
||||
{{\"min\": LO, \"max\": HI}}, got an unexpected \"{bound}\""
|
||||
))
|
||||
}
|
||||
};
|
||||
if slot.is_some() {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} band sets \"{bound}\" more than once"
|
||||
));
|
||||
}
|
||||
let serde_json::Value::Number(n) = v else {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} band needs numeric bounds, got {bound}: {v}"
|
||||
));
|
||||
};
|
||||
*slot = Some(checked_value(field, &n)?);
|
||||
}
|
||||
let (Some(lo), Some(hi)) = (min, max) else {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {key} band must be exactly \
|
||||
{{\"min\": LO, \"max\": HI}} with numeric bounds"
|
||||
));
|
||||
};
|
||||
// `lo <= hi`, `top_k`'s discontiguous domain and the band-under-`allow`
|
||||
// rule are all properties of the finished spec, so they live in
|
||||
// `validate_spec` and hold for a hand-built `SamplingOverrides` too.
|
||||
Ok(ParamSpec::Range { lo, hi })
|
||||
}
|
||||
|
||||
/// Check one configured value against its parameter's domain, at startup
|
||||
/// instead of per request. Written as positive containment so a NaN bound
|
||||
/// fails too.
|
||||
///
|
||||
/// These are the OpenAI API's domains, which are NARROWER than what the engine
|
||||
/// itself accepts (`SamplingParams.verify` requires only that `temperature` be
|
||||
/// non-negative and finite, so it would take `temperature: 5`). Narrower is
|
||||
/// deliberate: the values here are injected into request bodies, and a fleet
|
||||
/// contract outside the range every OpenAI client library validates against is
|
||||
/// far more likely a typo than an intent. The one exception is `top_k`, where
|
||||
/// `-1` is the engine's own "disable / whole vocabulary" spelling and its
|
||||
/// default — a legitimate thing to fix fleet-wide. Note `top_k: 1` is greedy
|
||||
/// decoding, NOT "disabled".
|
||||
fn checked_value(field: SamplingField, n: &serde_json::Number) -> Result<f64> {
|
||||
let name = field.wire_name();
|
||||
// `as_f64` is infallible for a JSON number unless serde_json's
|
||||
// `arbitrary_precision` is on (it is not); kept total rather than
|
||||
// `expect`-ing, so enabling that feature can't turn config into a panic.
|
||||
let v = n.as_f64().ok_or_else(|| {
|
||||
anyhow!("--override-sampling-params: {name} ({n}) is not a finite number")
|
||||
})?;
|
||||
// The operator's own literal is what the message quotes, not the parsed
|
||||
// f64: `1e2` should read back as `1e2`.
|
||||
check_domain(field, v, &n.to_string())?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The domain half of [`checked_value`], over an f64 that may not have come
|
||||
/// from a literal (a band's bounds are stored as f64). `shown` is what the
|
||||
/// error quotes back to the operator.
|
||||
fn check_domain(field: SamplingField, v: f64, shown: &str) -> Result<()> {
|
||||
let name = field.wire_name();
|
||||
let (ok, domain) = match field {
|
||||
SamplingField::Temperature => ((0.0..=2.0).contains(&v), "in [0, 2]"),
|
||||
SamplingField::TopP => (v > 0.0 && v <= 1.0, "in (0, 1]"),
|
||||
SamplingField::TopK => (v >= 1.0 || v == -1.0, ">= 1, or -1 to disable"),
|
||||
// Not an OpenAI parameter: `min_p` is the engine's own nucleus floor,
|
||||
// and 0 is its default (disabled), so the whole [0, 1] range is
|
||||
// legitimate to fix fleet-wide.
|
||||
SamplingField::MinP => ((0.0..=1.0).contains(&v), "in [0, 1]"),
|
||||
// Also engine-only. 1.0 is "no penalty"; the engine requires > 0, and
|
||||
// values above ~2 degrade output badly enough that a fleet-wide pin
|
||||
// there is far more likely a typo than an intent.
|
||||
SamplingField::RepetitionPenalty => (v > 0.0 && v <= 2.0, "in (0, 2]"),
|
||||
SamplingField::FrequencyPenalty | SamplingField::PresencePenalty => {
|
||||
((-2.0..=2.0).contains(&v), "in [-2, 2]")
|
||||
}
|
||||
// OpenAI caps `n` at 128. Unbounded here, a typo'd digit would be
|
||||
// injected into every request that omits `n` and fan each one out to
|
||||
// that many sequences at the engine — the exact per-request failure
|
||||
// this startup check exists to convert into a launch failure.
|
||||
SamplingField::N => ((1.0..=128.0).contains(&v), "in [1, 128]"),
|
||||
};
|
||||
if !ok {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {name} ({shown}) must be {domain}"
|
||||
));
|
||||
}
|
||||
if field.is_integral() {
|
||||
if v.fract() != 0.0 {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {name} ({shown}) must be a whole number"
|
||||
));
|
||||
}
|
||||
// `canonical_number` casts to `i64`, and a Rust float-to-int cast
|
||||
// SATURATES rather than failing, so a literal past the i64 range would
|
||||
// silently become `i64::MAX` in every forwarded body. The exactly
|
||||
// convertible f64s are [-2^63, 2^63), which is this half-open range
|
||||
// written as positive containment — `i64::MAX as f64` rounds UP to
|
||||
// 2^63, so an inclusive `<=` against it would admit 2^63 itself and
|
||||
// saturate exactly as described.
|
||||
if !(i64::MIN as f64..i64::MAX as f64).contains(&v) {
|
||||
return Err(anyhow!(
|
||||
"--override-sampling-params: {name} ({shown}) is too large to forward"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Normalize an integer-typed parameter's literal so injection writes `1`
|
||||
/// rather than `1.0` for a config that spelled it `1.0` — the engine types
|
||||
/// these fields as `int`, and the forwarded body should look like what a
|
||||
/// client would have sent. Non-integral fields keep the operator's literal.
|
||||
fn canonical_number(
|
||||
field: SamplingField,
|
||||
value: f64,
|
||||
literal: serde_json::Number,
|
||||
) -> serde_json::Number {
|
||||
if field.is_integral() {
|
||||
serde_json::Number::from(value as i64)
|
||||
} else {
|
||||
literal
|
||||
}
|
||||
}
|
||||
|
||||
/// The supported `--override-sampling-params` keys, for error messages.
|
||||
fn supported_fields() -> String {
|
||||
SamplingField::ALL
|
||||
.iter()
|
||||
.map(|f| f.wire_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// A JSON object decoded to its entries IN ORDER, keeping a repeated key
|
||||
/// instead of collapsing it. See the module WHY note.
|
||||
struct ObjectEntries(Vec<(String, ParamValue)>);
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for ObjectEntries {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct EntryVisitor;
|
||||
impl<'de> serde::de::Visitor<'de> for EntryVisitor {
|
||||
type Value = ObjectEntries;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a JSON object")
|
||||
}
|
||||
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> Result<ObjectEntries, M::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some(entry) = map.next_entry::<String, ParamValue>()? {
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(ObjectEntries(entries))
|
||||
}
|
||||
}
|
||||
// `deserialize_map` rejects a non-object with the type error the
|
||||
// caller wraps into the flag's own message.
|
||||
d.deserialize_map(EntryVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter's raw value: a number, a band's entries, or anything else.
|
||||
/// `Other` keeps the offending value so the caller can name it, rather than
|
||||
/// degrading a wrong-type message into a serde type error behind the outer
|
||||
/// object's context.
|
||||
enum ParamValue {
|
||||
Number(serde_json::Number),
|
||||
Band(Vec<(String, serde_json::Value)>),
|
||||
Other(serde_json::Value),
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for ParamValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct ValueVisitor;
|
||||
impl<'de> serde::de::Visitor<'de> for ValueVisitor {
|
||||
type Value = ParamValue;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a number or a {\"min\": LO, \"max\": HI} band")
|
||||
}
|
||||
|
||||
fn visit_i64<E>(self, v: i64) -> Result<ParamValue, E> {
|
||||
Ok(ParamValue::Number(v.into()))
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, v: u64) -> Result<ParamValue, E> {
|
||||
Ok(ParamValue::Number(v.into()))
|
||||
}
|
||||
|
||||
fn visit_f64<E>(self, v: f64) -> Result<ParamValue, E> {
|
||||
// `from_f64` is None only for NaN/inf, which JSON cannot
|
||||
// express; `checked_value` rejects the null either way.
|
||||
Ok(match serde_json::Number::from_f64(v) {
|
||||
Some(n) => ParamValue::Number(n),
|
||||
None => ParamValue::Other(serde_json::Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> Result<ParamValue, M::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some(entry) = map.next_entry::<String, serde_json::Value>()? {
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(ParamValue::Band(entries))
|
||||
}
|
||||
|
||||
fn visit_bool<E>(self, v: bool) -> Result<ParamValue, E> {
|
||||
Ok(ParamValue::Other(v.into()))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<ParamValue, E> {
|
||||
Ok(ParamValue::Other(v.into()))
|
||||
}
|
||||
|
||||
/// JSON `null`. There is deliberately no `visit_none`: this type
|
||||
/// is only ever reached through `deserialize_any`, which routes
|
||||
/// null here and never to the `Option` hook.
|
||||
fn visit_unit<E>(self) -> Result<ParamValue, E> {
|
||||
Ok(ParamValue::Other(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> Result<ParamValue, A::Error> {
|
||||
let mut items = Vec::new();
|
||||
while let Some(v) = seq.next_element::<serde_json::Value>()? {
|
||||
items.push(v);
|
||||
}
|
||||
Ok(ParamValue::Other(serde_json::Value::Array(items)))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(ValueVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(raw: &str) -> Result<SamplingOverrides> {
|
||||
parse_sampling_overrides(raw, ConflictPolicy::Reject)
|
||||
}
|
||||
|
||||
/// The exact value configured for one parameter, as an f64.
|
||||
fn exact_of(o: &SamplingOverrides, field: SamplingField) -> Option<f64> {
|
||||
match o.params.get(&field) {
|
||||
Some(ParamSpec::Exact(n)) => n.as_f64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_supported_parameter() {
|
||||
let o = parse(
|
||||
r#"{"temperature": 1, "top_p": 1.0, "top_k": 20,
|
||||
"frequency_penalty": 0, "presence_penalty": -0.5, "n": 1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(exact_of(&o, SamplingField::Temperature), Some(1.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::TopP), Some(1.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::TopK), Some(20.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::FrequencyPenalty), Some(0.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::PresencePenalty), Some(-0.5));
|
||||
assert_eq!(exact_of(&o, SamplingField::N), Some(1.0));
|
||||
assert_eq!(o.conflict, ConflictPolicy::Reject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_band() {
|
||||
let o = parse(r#"{"temperature": {"min": 0, "max": 1}}"#).unwrap();
|
||||
assert_eq!(
|
||||
o.params.get(&SamplingField::Temperature),
|
||||
Some(&ParamSpec::Range { lo: 0.0, hi: 1.0 })
|
||||
);
|
||||
}
|
||||
|
||||
/// A band can only ever reject, so under `allow` it would be dead config.
|
||||
#[test]
|
||||
fn a_band_under_allow_is_a_startup_error() {
|
||||
let err = parse_sampling_overrides(
|
||||
r#"{"temperature": {"min": 0, "max": 1}}"#,
|
||||
ConflictPolicy::Allow,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("requires --sampling-param-conflict reject"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every malformed spelling fails the launch, naming the offending key and
|
||||
/// its domain.
|
||||
#[test]
|
||||
fn rejects_malformed_input() {
|
||||
for (json, needle) in [
|
||||
("not json", "must be a JSON object"),
|
||||
("[1, 2]", "must be a JSON object"),
|
||||
(r#"[["temperature", 1]]"#, "must be a JSON object"),
|
||||
("{}", "is empty"),
|
||||
(r#"{"temp": 1}"#, "unknown parameter"),
|
||||
(r#"{"max_tokens": 100}"#, "unknown parameter"),
|
||||
(r#"{"temperature": 2.5}"#, "in [0, 2]"),
|
||||
(r#"{"temperature": -0.1}"#, "in [0, 2]"),
|
||||
(r#"{"top_p": 0}"#, "in (0, 1]"),
|
||||
(r#"{"top_p": 1.5}"#, "in (0, 1]"),
|
||||
(r#"{"top_k": 0}"#, ">= 1"),
|
||||
(r#"{"top_k": -2}"#, ">= 1"),
|
||||
(r#"{"top_k": 1.5}"#, "whole number"),
|
||||
(r#"{"frequency_penalty": 2.1}"#, "in [-2, 2]"),
|
||||
(r#"{"presence_penalty": -2.1}"#, "in [-2, 2]"),
|
||||
(r#"{"n": 0}"#, "in [1, 128]"),
|
||||
(r#"{"n": 129}"#, "in [1, 128]"),
|
||||
(r#"{"n": 1.5}"#, "whole number"),
|
||||
(r#"{"n": 1e20}"#, "in [1, 128]"),
|
||||
(r#"{"temperature": "1"}"#, "must be a number"),
|
||||
(r#"{"temperature": true}"#, "must be a number"),
|
||||
(r#"{"temperature": null}"#, "must be a number"),
|
||||
(r#"{"temperature": [1]}"#, "must be a number"),
|
||||
(
|
||||
r#"{"temperature": 0, "temperature": 1}"#,
|
||||
"temperature is set more than once",
|
||||
),
|
||||
(r#"{"temperature": {"min": 1, "max": 0}}"#, "min <= max"),
|
||||
(r#"{"temperature": {"min": 0, "max": 2.5}}"#, "in [0, 2]"),
|
||||
(r#"{"temperature": {"min": 0}}"#, "band must be exactly"),
|
||||
(
|
||||
r#"{"temperature": {"min": 0, "max": 1, "typo": 2}}"#,
|
||||
"unexpected \"typo\"",
|
||||
),
|
||||
(
|
||||
r#"{"temperature": {"min": 0, "max": 1, "max": 0.5}}"#,
|
||||
"band sets \"max\" more than once",
|
||||
),
|
||||
(
|
||||
r#"{"temperature": {"min": "0", "max": "1"}}"#,
|
||||
"needs numeric bounds",
|
||||
),
|
||||
(
|
||||
r#"{"top_k": {"min": -1, "max": 100}}"#,
|
||||
"band bounds must both be >= 1",
|
||||
),
|
||||
] {
|
||||
let err = parse(json).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{json}: got {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `top_p`'s domain is (0, 1] — the inclusive upper bound is valid — and
|
||||
/// `top_k` has no upper bound, so a wide sample width parses.
|
||||
#[test]
|
||||
fn top_p_upper_bound_and_wide_top_k_are_accepted() {
|
||||
let o = parse(r#"{"top_p": 1, "top_k": 1000}"#).unwrap();
|
||||
assert_eq!(exact_of(&o, SamplingField::TopP), Some(1.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::TopK), Some(1000.0));
|
||||
}
|
||||
|
||||
/// `top_k: -1` is the engine's own "disable / whole vocabulary" spelling
|
||||
/// (and its default), so a fleet may legitimately fix `top_k` to it —
|
||||
/// unlike every other parameter, whose domain is the OpenAI one.
|
||||
#[test]
|
||||
fn top_k_accepts_the_engines_disable_sentinel() {
|
||||
let o = parse(r#"{"top_k": -1}"#).unwrap();
|
||||
assert_eq!(exact_of(&o, SamplingField::TopK), Some(-1.0));
|
||||
}
|
||||
|
||||
/// An integer-typed parameter spelled as a float is normalized, so the
|
||||
/// forwarded body carries `1` and not `1.0` for a field the engine types
|
||||
/// as `int`.
|
||||
#[test]
|
||||
fn integral_params_are_normalized_to_integers() {
|
||||
let o = parse(r#"{"n": 1.0, "top_k": 20.0}"#).unwrap();
|
||||
for field in [SamplingField::N, SamplingField::TopK] {
|
||||
let Some(ParamSpec::Exact(n)) = o.params.get(&field) else {
|
||||
panic!("{field:?} must be an exact value");
|
||||
};
|
||||
assert!(n.is_i64(), "{field:?} kept a float literal: {n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire name is the request-body key in both directions, so a
|
||||
/// configured key round-trips back to its field.
|
||||
#[test]
|
||||
fn every_field_round_trips_through_its_wire_name() {
|
||||
for field in SamplingField::ALL {
|
||||
assert_eq!(
|
||||
SamplingField::from_wire_name(field.wire_name()),
|
||||
Some(field)
|
||||
);
|
||||
}
|
||||
assert_eq!(SamplingField::from_wire_name("max_tokens"), None);
|
||||
}
|
||||
/// `canonical_number` casts to `i64` and a Rust float-to-int cast
|
||||
/// SATURATES, so a literal past the i64 range must fail the launch rather
|
||||
/// than be injected as `i64::MAX`. The boundary case is the trap: `i64::MAX
|
||||
/// as f64` rounds UP to 2^63, so a `>` comparison against it admits 2^63
|
||||
/// itself.
|
||||
#[test]
|
||||
fn integral_literals_beyond_i64_fail_the_launch() {
|
||||
for raw in [
|
||||
// 2^63 exactly — equal to `i64::MAX as f64`, not greater than it.
|
||||
r#"{"top_k": 9223372036854775808}"#,
|
||||
// i64::MAX, which also rounds to 2^63 as an f64.
|
||||
r#"{"top_k": 9223372036854775807}"#,
|
||||
r#"{"top_k": 1e30}"#,
|
||||
] {
|
||||
let err = parse(raw).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to forward"), "{raw}: got {err}");
|
||||
}
|
||||
// Nothing in range regressed.
|
||||
assert_eq!(
|
||||
exact_of(
|
||||
&parse(r#"{"top_k": 1000000}"#).unwrap(),
|
||||
SamplingField::TopK
|
||||
),
|
||||
Some(1_000_000.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// `min_p` and `repetition_penalty` are the only two parameters besides
|
||||
/// `temperature`/`top_p`/`top_k` that the engine resolves from the model's
|
||||
/// own `generation_config`, so they are exactly the ones a fleet-wide pin
|
||||
/// exists to stop drifting when an image is swapped. Rejecting them as
|
||||
/// unknown keys would crash-loop the router for the operator who needs the
|
||||
/// flag most.
|
||||
#[test]
|
||||
fn governs_the_engine_defaulted_parameters() {
|
||||
let o = parse(r#"{"min_p": 0.05, "repetition_penalty": 1.1}"#).unwrap();
|
||||
assert_eq!(exact_of(&o, SamplingField::MinP), Some(0.05));
|
||||
assert_eq!(exact_of(&o, SamplingField::RepetitionPenalty), Some(1.1));
|
||||
|
||||
// Both defaults are legitimate fleet-wide pins.
|
||||
let o = parse(r#"{"min_p": 0, "repetition_penalty": 1}"#).unwrap();
|
||||
assert_eq!(exact_of(&o, SamplingField::MinP), Some(0.0));
|
||||
assert_eq!(exact_of(&o, SamplingField::RepetitionPenalty), Some(1.0));
|
||||
|
||||
for (raw, needle) in [
|
||||
(r#"{"min_p": -0.1}"#, "in [0, 1]"),
|
||||
(r#"{"min_p": 1.1}"#, "in [0, 1]"),
|
||||
(r#"{"repetition_penalty": 0}"#, "in (0, 2]"),
|
||||
(r#"{"repetition_penalty": 2.5}"#, "in (0, 2]"),
|
||||
] {
|
||||
let err = parse(raw).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{raw}: got {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ALL` is what `from_wire_name` and `supported_fields` iterate, so a
|
||||
/// field missing from it is silently rejected at startup as an unknown
|
||||
/// key — invisible to any test that also iterates `ALL`. Pin the length
|
||||
/// and the slot mapping against the wire names instead.
|
||||
#[test]
|
||||
fn all_covers_every_field_exactly_once() {
|
||||
// The slot mapping itself is asserted at compile time (see the
|
||||
// `const _` block above `parse_sampling_overrides`); what only a test
|
||||
// can catch is a field missing from `ALL` entirely, which is why the
|
||||
// names below are written out rather than derived from it.
|
||||
let names: std::collections::BTreeSet<_> =
|
||||
SamplingField::ALL.iter().map(|f| f.wire_name()).collect();
|
||||
assert_eq!(names.len(), SamplingField::ALL.len(), "duplicate wire name");
|
||||
for name in [
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"min_p",
|
||||
"repetition_penalty",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"n",
|
||||
] {
|
||||
assert!(names.contains(name), "{name} is not governed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The struct-level invariants must hold for a `SamplingOverrides` that
|
||||
/// never went through the parser — a test fixture, a future config file or
|
||||
/// admin API. An inverted band is the nastiest of these: `(lo..=hi)`
|
||||
/// contains nothing, so it would 400 every request naming the parameter.
|
||||
#[test]
|
||||
fn validate_rejects_hand_built_specs_the_parser_would_refuse() {
|
||||
let bad = [
|
||||
(
|
||||
ParamSpec::Exact(serde_json::Number::from_f64(50.0).unwrap()),
|
||||
ConflictPolicy::Reject,
|
||||
"in [0, 2]",
|
||||
),
|
||||
(
|
||||
ParamSpec::Range { lo: 1.0, hi: 0.0 },
|
||||
ConflictPolicy::Reject,
|
||||
"min <= max",
|
||||
),
|
||||
(
|
||||
ParamSpec::Range { lo: 0.0, hi: 1.0 },
|
||||
ConflictPolicy::Allow,
|
||||
"requires --sampling-param-conflict reject",
|
||||
),
|
||||
];
|
||||
for (spec, conflict, needle) in bad {
|
||||
let o = SamplingOverrides {
|
||||
params: [(SamplingField::Temperature, spec)].into_iter().collect(),
|
||||
conflict,
|
||||
};
|
||||
let err = o.validate().unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "got {err}");
|
||||
}
|
||||
|
||||
// A well-formed contract still validates.
|
||||
parse(r#"{"temperature": 1, "min_p": 0.05}"#)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::sampling::SamplingOverrides;
|
||||
use serde::Deserialize;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
@@ -312,6 +313,12 @@ pub struct ModelConfig {
|
||||
pub fused: Option<Vec<FusedTerm>>,
|
||||
/// Hard constraints applied before policy selection.
|
||||
pub eligibility: Option<EligibilityConfig>,
|
||||
/// Sampling parameters fixed fleet-wide for this model, and what happens
|
||||
/// to a request that sends a different value: a 400 before admission, or
|
||||
/// the client value forwarded untouched. Either way the configured value
|
||||
/// is injected when the request omits the field — see
|
||||
/// [`SamplingOverrides`]. Empty (default) preserves today's behavior.
|
||||
pub sampling_overrides: SamplingOverrides,
|
||||
}
|
||||
|
||||
/// External KV Indexer client settings.
|
||||
|
||||
@@ -345,6 +345,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -133,6 +133,7 @@ impl AppContext {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: crate::config::DiscoveryBackend::StaticUrls(
|
||||
crate::config::StaticUrlsDiscoveryConfig {
|
||||
|
||||
@@ -59,6 +59,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
};
|
||||
let app = crate::server::app::build_router(std::sync::Arc::new(ctx));
|
||||
let res = app
|
||||
|
||||
@@ -126,6 +126,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: crate::config::DiscoveryBackend::StaticUrls(
|
||||
crate::config::StaticUrlsDiscoveryConfig {
|
||||
|
||||
@@ -240,6 +240,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: crate::config::DiscoveryBackend::StaticUrls(
|
||||
crate::config::StaticUrlsDiscoveryConfig {
|
||||
|
||||
@@ -601,6 +601,7 @@ mod tests {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://test:30000".into()],
|
||||
|
||||
@@ -138,6 +138,7 @@ async fn static_urls_pd_role_resolved_end_to_end() {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec![url.clone()],
|
||||
|
||||
@@ -69,6 +69,7 @@ fn build_app_context(
|
||||
affinity,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -42,6 +42,7 @@ fn config_for(_worker_url: &str) -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn config() -> Config {
|
||||
sticky: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -46,6 +46,7 @@ async fn failover_when_one_worker_dies() {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec![w1.url.clone(), w2.url.clone(), w3.url.clone()],
|
||||
|
||||
@@ -52,6 +52,7 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -39,6 +39,7 @@ async fn forwards_whitelisted_headers_strips_others() {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -54,6 +54,7 @@ fn config() -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -53,6 +53,7 @@ fn config() -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -50,6 +50,7 @@ fn config() -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -156,6 +156,7 @@ fn config(policy: PolicyKind) -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -69,6 +69,7 @@ fn config() -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -55,6 +55,7 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
@@ -46,6 +46,7 @@ fn config(_worker_url: &str) -> Config {
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
sampling_overrides: Default::default(),
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
|
||||
Reference in New Issue
Block a user