Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
82d3564
feat(bud-auth): realtime session settings, per-modality rates, and li…
Sep 27, 2026
661f2f6
fix(gateway): no process vendor keys on Bud-mode socket paths (FRD-02…
Sep 27, 2026
dda4297
fix(gateway): connection-slot acquire deadlocked with DEBUG logging on
Sep 27, 2026
2a2ae5a
feat(gateway): /v1/realtime serves Bud deployments over OpenAI Realti…
Sep 27, 2026
c85e5d3
fix(gateway): clippy findings in the /v1/realtime relay
Sep 27, 2026
cc3c559
feat(gateway): /ws legs address Bud deployments (FRD-023 RT6)
Sep 27, 2026
0e632ca
fix(gateway): realtime model ids verbatim, merged session updates, cu…
Sep 27, 2026
51f9f4c
feat(gateway): xAI realtime deployments over the GA relay (FRD-023 RT…
Sep 28, 2026
548618c
fix(gateway): a refused /v1/realtime upgrade speaks OpenAI's error en…
Sep 28, 2026
cdf025c
feat(gateway): realtime providers report usage and goAway, reconnect …
Sep 28, 2026
e2e9a39
feat(gateway): GA translate engine on /v1/realtime for Gemini Live, N…
Sep 28, 2026
c208bd0
Merge branch 'feat/realtime-openai-ga' into feat/realtime-translate-rt7
Sep 28, 2026
6cd3850
fix(gateway): TCP_NODELAY on accepted client sockets (FRD-023 TC-PERF…
Sep 28, 2026
c667769
fix(gateway): a per-minute vendor's time is metered even without a du…
Sep 28, 2026
dcdc491
Merge branch 'feat/realtime-openai-ga' into feat/realtime-translate-rt7
Sep 28, 2026
1aa7b21
fix(gateway): a translated session with no turn_detection reports ser…
Sep 28, 2026
eae903a
fix(gateway): space the sentences a translated vendor sends as separa…
Sep 28, 2026
06ac404
fix(gateway): send a deployment's "turn detection off" to OpenAI as null
Sep 28, 2026
451967e
fix(gateway): build without dag-routing, test keys from a clean check…
Sep 28, 2026
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
101 changes: 100 additions & 1 deletion bud-auth/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,30 @@ pub struct VoicePricing {
/// How many units `cost_per_unit` covers. `0` is carried as published and prices nothing,
/// rather than dividing by it.
pub per_units: u64,
/// FRD-023 §5.10: a realtime deployment's per-modality rates, each per `per_units` tokens
/// (`transcription_per_minute` per minute). Keys are restricted to [`REALTIME_RATE_KEYS`].
/// Empty for every other unit and every non-realtime deployment.
pub rates: BTreeMap<String, f64>,
}

/// The rate keys a realtime price may carry (CONTRACTS C1). A closed set: a key outside it is
/// ignored with a warning rather than guessed at, and a component whose rate is absent is
/// recorded as UNPRICED, never as free.
pub const REALTIME_RATE_KEYS: &[&str] = &[
"input_text",
"input_audio",
"input_image",
"cached_input_text",
"cached_input_audio",
"cached_input_image",
"output_text",
"output_audio",
"transcription_per_minute",
"transcription_input_audio",
"transcription_input_text",
"transcription_output_text",
];

/// Read a published `pricing` block, or `None` with a warning when it cannot be used.
///
/// Tolerant of the shapes a Python publisher produces: numbers or numeric strings, a missing
Expand Down Expand Up @@ -269,8 +291,15 @@ pub fn parse_pricing(endpoint_id: &str, raw: &serde_json::Value) -> Option<Voice
Some(u) if !u.is_empty() => u.to_ascii_lowercase(),
_ => return refuse("no unit"),
};
let rates = parse_rates(endpoint_id, fields.get("rates"));
// FRD-023: a TOKEN price on the audio plane is a realtime price, and is ONLY its rates. With
// none it would price every response at zero, so it is refused like any unusable price.
if unit == "token" && rates.is_empty() {
return refuse("a token price on a voice endpoint needs per-modality rates");
}
let cost_per_unit = match fields.get("cost_per_unit").and_then(number) {
Some(c) if c.is_finite() && c >= 0.0 => c,
None if unit == "token" => 0.0,
_ => return refuse("cost_per_unit is not a non-negative number"),
};
let per_units = match fields.get("per_units") {
Expand All @@ -293,9 +322,50 @@ pub fn parse_pricing(endpoint_id: &str, raw: &serde_json::Value) -> Option<Voice
cost_per_unit,
currency,
per_units,
rates,
})
}

/// Read a `rates` object: numbers or numeric strings, finite and non-negative, keys restricted to
/// [`REALTIME_RATE_KEYS`]. Anything else is dropped by KEY with a warning; the rest is kept.
fn parse_rates(endpoint_id: &str, raw: Option<&serde_json::Value>) -> BTreeMap<String, f64> {
let mut out = BTreeMap::new();
let fields = match raw {
None | Some(serde_json::Value::Null) => return out,
Some(serde_json::Value::Object(fields)) => fields,
Some(_) => {
tracing::warn!(endpoint_id = %endpoint_id, "voice_table pricing.rates is not an object; ignored");
return out;
}
};
for (key, value) in fields {
if !REALTIME_RATE_KEYS.contains(&key.as_str()) {
tracing::warn!(
endpoint_id = %endpoint_id,
field = %key,
"voice_table pricing.rates carries a key this build does not price; ignored"
);
continue;
}
let rate = match value {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
};
match rate {
Some(r) if r.is_finite() && r >= 0.0 => {
out.insert(key.clone(), r);
}
_ => tracing::warn!(
endpoint_id = %endpoint_id,
field = %key,
"voice_table pricing rate is not a non-negative number; ignored"
),
}
}
out
}

/// A voice endpoint after hydration: the credential is already plaintext.
///
/// `PartialEq` but not `Eq`: the config block carries vendor float knobs (pitch, stability,
Expand Down Expand Up @@ -357,7 +427,8 @@ pub fn allowed_provider_params(vendor: &str) -> &'static [&'static str] {
.replace('-', "_")
.as_str()
{
"aws_polly" | "aws_transcribe" => &["region"],
// Nova 2 Sonic (realtime, FRD-023 RT7.2) signs Bedrock requests in this region.
"aws_polly" | "aws_transcribe" | "nova_sonic" => &["region"],
"google" => &["project_id", "location"],
"azure_openai" => &["api_version"],
_ => &[],
Expand Down Expand Up @@ -741,6 +812,7 @@ mod tests {
cost_per_unit: 0.0001,
currency: Some("USD".into()),
per_units: 1,
rates: BTreeMap::new(),
})
);
}
Expand Down Expand Up @@ -1176,6 +1248,33 @@ mod tests {
assert!(allowed_provider_params("deepgram").is_empty());
}

/// FRD-023 RT7.2 (CONTRACTS C7) — a Nova 2 Sonic realtime entry carries its REQUIRED region
/// in `provider_params`; dropping it at parse would leave the session unable to sign.
#[test]
fn nova_sonic_keeps_its_region_and_its_key_pair() {
assert_eq!(allowed_provider_params("nova_sonic"), &["region"]);
assert_eq!(allowed_provider_params("nova-sonic"), &["region"]);
let json = serde_json::json!({ "ep-nova": {
"vendor": "nova_sonic",
"credential": encrypt_like_budapp(
r#"{"access_key_id":"AKIDNOVA","secret_access_key":"s3cr3t"}"#
),
"endpoints": ["realtime_session"],
"model": "amazon.nova-2-sonic-v1:0",
"provider_params": { "region": "us-east-1", "endpoint_override": "https://evil" },
}})
.to_string();
let map = parse_voice_blob(&json, &decryptor()).unwrap();
let ep = map.get("ep-nova").unwrap();
assert_eq!(ep.provider_param("region"), Some("us-east-1"));
assert_eq!(ep.provider_param("endpoint_override"), None);
let parts = ep.credential_parts.as_ref().expect("the AWS pair splits");
assert_eq!(
parts.get("access_key_id").map(String::as_str),
Some("AKIDNOVA")
);
}

#[test]
fn aws_regions_are_checked_against_the_published_shape() {
for ok in [
Expand Down
179 changes: 174 additions & 5 deletions bud-auth/src/endpoint_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ pub struct TtsSettings {

/// Transcription defaults for a deployment.
///
/// The five streaming-only canonical features are absent by construction: they need a continuous
/// stream, budapp refuses them at publish naming the transport, and a field here would suggest
/// otherwise to the next person reading this struct.
/// The five streaming-only canonical features are not fields of their own: they need a continuous
/// stream, so budapp refuses them at the top of `stt` and publishes them under `stt.streaming`
/// ([`SttStreaming`]), which only the `/ws` transport applies (FRD-023 WP-RT6.4, FR-WS-4).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct SttSettings {
// --- the four the handler used to hardcode, plus model and prompt ---
Expand Down Expand Up @@ -170,6 +170,26 @@ pub struct SttSettings {
/// request and changes the audio the vendor bills against, so it defaults off.
#[serde(default)]
pub noise_suppression: Option<bool>,

/// The streaming-only features, applied on `/ws` and ignored by the prerecorded upload.
#[serde(default)]
pub streaming: Option<SttStreaming>,
}

/// `stt.streaming`: the five canonical features that need a continuous audio stream
/// (FRD-023 WP-RT6.4). budapp validates the closed key set and the millisecond ranges.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct SttStreaming {
#[serde(default)]
pub interim_results: Option<bool>,
#[serde(default)]
pub vad_events: Option<bool>,
#[serde(default)]
pub endpointing_ms: Option<u32>,
#[serde(default)]
pub utterance_end_ms: Option<u32>,
#[serde(default)]
pub speech_begin_event: Option<bool>,
}

/// Translation defaults.
Expand All @@ -185,6 +205,121 @@ pub struct TranslationSettings {
pub partials: Option<bool>,
}

/// Input-transcription defaults for a realtime session (OpenAI GA `audio.input.transcription`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct RealtimeTranscription {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
}

/// What WaaV sends the vendor in its first `session.update`, unless the client overrides it
/// (FRD-023 §5.6: request > deployment > vendor default).
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct RealtimeDefaults {
#[serde(default)]
pub voice: Option<String>,
#[serde(default)]
pub instructions: Option<String>,
#[serde(default)]
pub output_modalities: Option<Vec<String>>,
/// Passed to the vendor as written: `{"type": "server_vad" | "semantic_vad", …}` or `null`.
/// budapp validated its shape; the vendor owns its semantics.
#[serde(default)]
pub turn_detection: Option<serde_json::Value>,
#[serde(default)]
pub input_transcription: Option<RealtimeTranscription>,
#[serde(default)]
pub noise_reduction: Option<String>,
#[serde(default)]
pub max_output_tokens: Option<u32>,
#[serde(default)]
pub speed: Option<f64>,
}

/// Per-deployment session limits. Absent means the gateway's ceiling applies.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct RealtimeLimits {
#[serde(default)]
pub max_session_seconds: Option<u64>,
#[serde(default)]
pub idle_timeout_seconds: Option<u64>,
}

/// What a client may change on a realtime session (FRD-023 D-11, S-5).
///
/// Every field is `Option` like the rest of this module, but the ACCESSORS apply the secure
/// default: a stored prompt, an MCP connector and a trace belong to the VENDOR ORG, which every
/// project sharing the credential shares, so they are off unless the deployment turns them on.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct RealtimePolicy {
#[serde(default)]
pub allow_client_instructions: Option<bool>,
#[serde(default)]
pub allow_mcp_tools: Option<bool>,
#[serde(default)]
pub allow_prompt_references: Option<bool>,
#[serde(default)]
pub allow_image_input: Option<bool>,
/// Transcription models a client may select. Absent = any; present = exactly these.
#[serde(default)]
pub input_transcription_models: Option<Vec<String>>,
}

impl RealtimePolicy {
pub fn allows_client_instructions(&self) -> bool {
self.allow_client_instructions.unwrap_or(true)
}

pub fn allows_mcp_tools(&self) -> bool {
self.allow_mcp_tools.unwrap_or(false)
}

pub fn allows_prompt_references(&self) -> bool {
self.allow_prompt_references.unwrap_or(false)
}

pub fn allows_image_input(&self) -> bool {
self.allow_image_input.unwrap_or(true)
}

pub fn allows_transcription_model(&self, model: &str) -> bool {
match &self.input_transcription_models {
None => true,
Some(list) => list.iter().any(|m| m == model),
}
}
}

/// A realtime (speech-to-speech) deployment's session settings (FRD-023 §5.3).
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct RealtimeSettings {
/// `realtime` or `transcription`, derived by budapp from the model's modality.
#[serde(default)]
pub session_type: Option<String>,
#[serde(default)]
pub defaults: Option<RealtimeDefaults>,
#[serde(default)]
pub limits: Option<RealtimeLimits>,
#[serde(default)]
pub policy: Option<RealtimePolicy>,
}

impl RealtimeSettings {
/// The policy block, or an all-default one (every accessor at its secure default).
pub fn policy(&self) -> RealtimePolicy {
self.policy.clone().unwrap_or_default()
}

/// Whether this deployment serves transcription-only sessions.
pub fn is_transcription(&self) -> bool {
self.session_type.as_deref() == Some("transcription")
}
}

/// The whole `config` object on a voice entry.
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct VoiceEndpointSettings {
Expand All @@ -194,11 +329,17 @@ pub struct VoiceEndpointSettings {
pub stt: Option<SttSettings>,
#[serde(default)]
pub translation: Option<TranslationSettings>,
/// FRD-023: the realtime session block, on `realtime_session` deployments only.
#[serde(default)]
pub realtime: Option<RealtimeSettings>,
}

impl VoiceEndpointSettings {
pub fn is_empty(&self) -> bool {
self.tts.is_none() && self.stt.is_none() && self.translation.is_none()
self.tts.is_none()
&& self.stt.is_none()
&& self.translation.is_none()
&& self.realtime.is_none()
}

/// The tts block, or an all-`None` one. Saves every call site an `unwrap_or_default` clone.
Expand Down Expand Up @@ -265,11 +406,14 @@ const KNOWN_STT: &[&str] = &[
"alternatives",
"sentiment",
"noise_suppression",
"streaming",
];

const KNOWN_TRANSLATION: &[&str] = &["target_languages", "translate_to_english", "partials"];

const KNOWN_SECTIONS: &[&str] = &["tts", "stt", "translation"];
const KNOWN_REALTIME: &[&str] = &["session_type", "defaults", "limits", "policy"];

const KNOWN_SECTIONS: &[&str] = &["tts", "stt", "translation", "realtime"];

/// Parse a `config` object, warning about anything this build does not model.
///
Expand All @@ -286,6 +430,7 @@ pub fn parse_endpoint_settings(
("tts", KNOWN_TTS),
("stt", KNOWN_STT),
("translation", KNOWN_TRANSLATION),
("realtime", KNOWN_REALTIME),
] {
if let Some(serde_json::Value::Object(fields)) = sections.get(section) {
warn_unmodelled(
Expand Down Expand Up @@ -318,6 +463,8 @@ pub fn parse_endpoint_settings(
stt: section("stt").and_then(|v| parse_section(endpoint_id, "stt", v)),
translation: section("translation")
.and_then(|v| parse_section(endpoint_id, "translation", v)),
realtime: section("realtime")
.and_then(|v| parse_section(endpoint_id, "realtime", v)),
}
}
}
Expand Down Expand Up @@ -413,6 +560,28 @@ mod tests {
);
}

#[test]
fn streaming_features_are_modelled_under_stt_streaming() {
// TC-WS-11: budapp publishes the five streaming-only features here (WP-RT6.4).
let settings = parse(
r#"{"stt": {"diarization": true, "streaming": {"interim_results": true,
"vad_events": false, "endpointing_ms": 300, "utterance_end_ms": 1000,
"speech_begin_event": true}}}"#,
);
let stt = settings.stt.expect("stt parses");
assert_eq!(stt.diarization, Some(true));
let streaming = stt.streaming.expect("stt.streaming is modelled");
assert_eq!(streaming.interim_results, Some(true));
assert_eq!(streaming.vad_events, Some(false));
assert_eq!(streaming.endpointing_ms, Some(300));
assert_eq!(streaming.utterance_end_ms, Some(1000));
assert_eq!(streaming.speech_begin_event, Some(true));
assert!(
KNOWN_STT.contains(&"streaming"),
"no unmodelled-key warning for it"
);
}

#[test]
fn an_unknown_field_is_kept_not_fatal() {
// TC-CFG-06. This is the property that lets budapp and WaaV ship in either order.
Expand Down
Loading
Loading