Skip to content

Commit 6f75013

Browse files
jithinABclaude
andcommitted
feat(D-G6/D-G7/D-G9): settings delta + TTS context seam + usage metrics
D-G6 — sparse mid-call settings delta (Pipecat ServiceSettings parity): - SttSettingsDelta {model/language/encoding/sample_rate/punctuation/extra} (None = not in delta) + merge_into returning the CHANGED-field set (a field equal to the current value is not a change — no spurious reconnects). - BaseSTT::apply_settings_delta — merge + reconnect decision standardized in the base: reconnect ONLY when a connection-relevant field (model/language/encoding/sample_rate) changed; non-connection knobs store via set_config_only without touching the transport. extra overflow rides opaque and is never connection-relevant. Pins: merge-only-given + changed-set, equal-value no-op, non-connection 0 reconnects, connection field exactly 1 reconnect, extra roundtrip. D-G7 — standardized TTS audio-context seam: - BaseTTS::on_audio_context_interrupted (default delegates to clear(); WS context-aware providers can override to cancel the specific server-side context) + on_audio_context_completed (default no-op) — all 36 providers express the hook through the base, zero changes to speak() sites. - The barge-in path (VoiceManager::clear_tts) now calls the seam. Pins: default-interrupted ≡ clear() behavior, completed default no-op. D-G9 — provider usage metrics: - waav_tts_chars_total{provider} bumped at the VoiceManager speak choke point (covers every provider). - waav_llm_tokens_total{provider,kind} for prompt/completion/cache_read/ cache_creation/reasoning — kinds emitted ONLY when the wire usage object carried them (details objects parsed); exported at both response sites (sync + streaming) with a stable AdapterKind::as_str label. Pins: chars accumulate by text length; kinds split; unreported kinds absent. Floor: 6101/0 lib (all features) + default-features check + clippy clean. Live: Deepgram Aura TTS 4 voices green (speak path with chars counter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 982ba0b commit 6f75013

6 files changed

Lines changed: 351 additions & 3 deletions

File tree

gateway/src/core/llm/adapter.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ pub enum AdapterKind {
4646
Gemini,
4747
}
4848

49+
impl AdapterKind {
50+
/// Stable label for metrics (`provider` tag — no per-call alloc).
51+
pub fn as_str(&self) -> &'static str {
52+
match self {
53+
Self::OpenAi => "openai",
54+
Self::Anthropic => "anthropic",
55+
Self::Gemini => "gemini",
56+
}
57+
}
58+
}
59+
4960
/// A fully rendered HTTP request: vendor endpoint + auth headers + JSON body.
5061
#[derive(Debug, Clone)]
5162
pub struct RenderedRequest {

gateway/src/core/llm/mod.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,28 @@ pub struct Usage {
404404
// Client configuration + per-session history
405405
// =============================================================================
406406

407+
/// D-G9: export wire-reported token usage as counters. Kinds beyond
408+
/// prompt/completion come from the details objects (cached_tokens,
409+
/// reasoning_tokens) and are emitted ONLY when the wire carried them.
410+
fn export_usage_metrics(provider: &str, usage: &Usage) {
411+
use crate::core::metrics::bridge::count_llm_tokens;
412+
count_llm_tokens(provider, "prompt", usage.prompt_tokens as u64);
413+
count_llm_tokens(provider, "completion", usage.completion_tokens as u64);
414+
if let Some(d) = &usage.prompt_tokens_details {
415+
if let Some(n) = d.get("cached_tokens").and_then(|v| v.as_u64()) {
416+
count_llm_tokens(provider, "cache_read", n);
417+
}
418+
if let Some(n) = d.get("cache_creation_tokens").and_then(|v| v.as_u64()) {
419+
count_llm_tokens(provider, "cache_creation", n);
420+
}
421+
}
422+
if let Some(d) = &usage.completion_tokens_details
423+
&& let Some(n) = d.get("reasoning_tokens").and_then(|v| v.as_u64())
424+
{
425+
count_llm_tokens(provider, "reasoning", n);
426+
}
427+
}
428+
407429
/// LLM client configuration. Field-compatible with the former
408430
/// `LlmEndpointConfig` so the DAG node can keep deserializing the same JSON.
409431
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -882,7 +904,12 @@ impl LlmClient {
882904
content: parsed.message.content.clone().unwrap_or_default(),
883905
finish_reason: parsed.finish_reason,
884906
tool_calls: parsed.message.tool_calls.clone().unwrap_or_default(),
885-
usage: parsed.usage,
907+
usage: {
908+
if let Some(u) = &parsed.usage {
909+
export_usage_metrics(self.adapter.kind().as_str(), u);
910+
}
911+
parsed.usage
912+
},
886913
})
887914
}
888915

@@ -971,7 +998,12 @@ impl LlmClient {
971998
content: parsed.message.content.clone().unwrap_or_default(),
972999
finish_reason: parsed.finish_reason,
9731000
tool_calls: parsed.message.tool_calls.clone().unwrap_or_default(),
974-
usage: parsed.usage,
1001+
usage: {
1002+
if let Some(u) = &parsed.usage {
1003+
export_usage_metrics(self.adapter.kind().as_str(), u);
1004+
}
1005+
parsed.usage
1006+
},
9751007
})
9761008
}
9771009

gateway/src/core/metrics/bridge.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ pub const CIRCUIT_BREAKER_STATE: &str = "waav_circuit_breaker_state";
4747
/// `circuit_open`=breaker rejected the attempt). Makes the reconnect path observable (W-C1).
4848
pub const RECONNECTS_TOTAL: &str = "waav_reconnects_total";
4949

50+
/// D-G9: characters submitted to TTS synthesis, per provider (cost proxy).
51+
pub const TTS_CHARS_TOTAL: &str = "waav_tts_chars_total";
52+
53+
/// D-G9: LLM token usage by kind, per provider
54+
/// (`kind` ∈ prompt|completion|cache_read|cache_creation|reasoning —
55+
/// a kind is emitted only when the wire usage object carries it).
56+
pub const LLM_TOKENS_TOTAL: &str = "waav_llm_tokens_total";
57+
5058
// -----------------------------------------------------------------------------
5159
// Live latency-profiling series (per-turn + per-frame realtime budget).
5260
// `path` ∈ {conversation,dag}; `stage`/`outcome`/`queue` are fixed enums; `node`
@@ -276,6 +284,14 @@ fn describe_series() {
276284
"TTS time-to-first-byte (turn perspective) by path"
277285
);
278286
metrics::describe_counter!(TURNS_TOTAL, "Total turns by path and outcome");
287+
metrics::describe_counter!(
288+
TTS_CHARS_TOTAL,
289+
"Characters submitted to TTS synthesis, per provider (D-G9)"
290+
);
291+
metrics::describe_counter!(
292+
LLM_TOKENS_TOTAL,
293+
"LLM token usage by kind (prompt/completion/cache_read/cache_creation/reasoning), per provider (D-G9)"
294+
);
279295
metrics::describe_counter!(
280296
TURN_BOTTLENECK_TOTAL,
281297
"Dominant-stage tally for completed turns"
@@ -345,6 +361,21 @@ pub fn set_circuit_breaker_state(provider: &str, state_code: u8) {
345361
/// Record a reconnect attempt outcome on `waav_reconnects_total`. `outcome` is one of
346362
/// `success` / `failure` / `exhausted` / `circuit_open`. Emitted from the streaming reconnect
347363
/// path so reconnects are observable (W-C1).
364+
/// D-G9: count TTS characters (call with `text.len()` at submit time).
365+
pub fn count_tts_chars(provider: &str, chars: usize) {
366+
counter!(TTS_CHARS_TOTAL, "provider" => provider.to_string()).increment(chars as u64);
367+
}
368+
369+
/// D-G9: count LLM tokens of one kind (emit only kinds the wire reported).
370+
pub fn count_llm_tokens(provider: &str, kind: &'static str, tokens: u64) {
371+
counter!(
372+
LLM_TOKENS_TOTAL,
373+
"provider" => provider.to_string(),
374+
"kind" => kind,
375+
)
376+
.increment(tokens);
377+
}
378+
348379
pub fn record_reconnect(provider: &str, outcome: &str) {
349380
counter!(
350381
RECONNECTS_TOTAL,
@@ -478,6 +509,38 @@ mod tests {
478509
);
479510
}
480511

512+
/// D-G9: TTS chars accumulate by text length; LLM tokens split by kind
513+
/// and only wire-reported kinds appear.
514+
#[test]
515+
fn dg9_usage_series_render() {
516+
let _ = metrics_handle();
517+
count_tts_chars("unit-tts", 27);
518+
count_tts_chars("unit-tts", 13);
519+
count_llm_tokens("unit-llm", "prompt", 100);
520+
count_llm_tokens("unit-llm", "completion", 40);
521+
count_llm_tokens("unit-llm", "reasoning", 7);
522+
523+
let text = render();
524+
let chars_line = text
525+
.lines()
526+
.find(|l| l.starts_with(TTS_CHARS_TOTAL) && l.contains("unit-tts"))
527+
.expect("tts chars series present");
528+
assert!(chars_line.ends_with(" 40"), "27+13 chars: {chars_line}");
529+
assert!(
530+
text.lines().any(|l| l.contains(LLM_TOKENS_TOTAL)
531+
&& l.contains("unit-llm")
532+
&& l.contains("kind=\"reasoning\"")
533+
&& l.ends_with(" 7")),
534+
"reasoning kind present with its count"
535+
);
536+
assert!(
537+
!text.lines().any(|l| l.contains(LLM_TOKENS_TOTAL)
538+
&& l.contains("unit-llm")
539+
&& l.contains("kind=\"cache_read\"")),
540+
"kinds the wire never reported must not appear"
541+
);
542+
}
543+
481544
#[test]
482545
fn profiling_series_render() {
483546
let _ = metrics_handle();

0 commit comments

Comments
 (0)