use serde::Deserialize;
use serde_json::Value;
use super::common::{self, count, present};
use super::openai;
use super::types::{NormalizedUsage, UsageCompleteness};
#[derive(Deserialize)]
struct Reply {
#[serde(default, deserialize_with = "common::object")]
usage: Option<Value>,
}
pub(super) fn openai_embedding(body: &[u8]) -> Option<NormalizedUsage> {
let reply: Reply = serde_json::from_slice(body).ok()?;
openai::from_usage(&reply.usage?)
}
pub(super) fn image_usage(usage: &Value) -> Option<NormalizedUsage> {
let mut normalized = openai::from_usage(usage)?;
if !normalized.metrics.contains_key("image_output_tokens") {
let output = normalized.tokens.output_tokens.unwrap_or_default();
common::metric(&mut normalized, "image_output_tokens", output);
}
common::flag_modalities(&mut normalized);
Some(normalized)
}
pub(super) fn qualify<const N: usize>(
usage: &mut NormalizedUsage,
qualifiers: [(&str, Option<String>); N],
) {
for (name, value) in qualifiers {
if let Some(value) = value.filter(|value| !value.is_empty()) {
usage.dimensions.insert(name.into(), value);
}
}
}
#[derive(Deserialize)]
struct ImageReply {
#[serde(default, deserialize_with = "common::object")]
usage: Option<Value>,
#[serde(default, deserialize_with = "common::length")]
data: Option<usize>,
#[serde(default, deserialize_with = "common::string")]
size: Option<String>,
#[serde(default, deserialize_with = "common::string")]
quality: Option<String>,
#[serde(default, deserialize_with = "common::string")]
output_format: Option<String>,
#[serde(default, deserialize_with = "common::string")]
background: Option<String>,
}
pub(super) fn image(body: &[u8]) -> Option<NormalizedUsage> {
let reply: ImageReply = serde_json::from_slice(body).ok()?;
let images = reply.data.unwrap_or_default();
let mut usage = match reply.usage.as_ref().and_then(image_usage) {
Some(usage) => usage,
None if images > 0 => NormalizedUsage {
completeness: UsageCompleteness::Complete,
..NormalizedUsage::default()
},
None => return None,
};
common::metric(&mut usage, "image_outputs", images as u64);
qualify(
&mut usage,
[
("size", reply.size),
("quality", reply.quality),
("output_format", reply.output_format),
("background", reply.background),
],
);
Some(usage)
}
fn transcription_usage(usage: &Value) -> Option<NormalizedUsage> {
let tokens = usage.get("type").and_then(Value::as_str) != Some("duration")
&& present(usage, "input_tokens")
&& present(usage, "output_tokens");
let seconds = usage.get("seconds").and_then(common::decimal);
if !tokens && seconds.is_none() {
return None;
}
let mut normalized = NormalizedUsage {
completeness: UsageCompleteness::Complete,
..NormalizedUsage::default()
};
if tokens {
normalized.tokens.input_tokens = count(usage, "input_tokens");
normalized.tokens.output_tokens = count(usage, "output_tokens");
if let Some(details) = usage.get("input_token_details") {
for modality in ["audio", "text"] {
common::metric(
&mut normalized,
&format!("{modality}_input_tokens"),
count(details, &format!("{modality}_tokens")).unwrap_or_default(),
);
}
common::flag_modalities(&mut normalized);
}
}
if let Some(seconds) = seconds {
normalized.metrics.insert("audio_seconds".into(), seconds);
}
Some(normalized)
}
pub(super) fn transcription(body: &[u8]) -> Option<NormalizedUsage> {
let reply: Reply = serde_json::from_slice(body).ok()?;
transcription_usage(&reply.usage?)
}
#[derive(Deserialize)]
struct TranscriptEvent {
#[serde(rename = "type", default, deserialize_with = "common::string")]
kind: Option<String>,
#[serde(default, deserialize_with = "common::object")]
usage: Option<Value>,
}
#[derive(Default)]
pub(super) struct TranscriptionStream {
usage: Option<NormalizedUsage>,
}
impl TranscriptionStream {
const DONE: &str = "transcript.text.done";
pub(super) fn event(&mut self, name: Option<&str>, data: &str) -> bool {
if name.is_some_and(|name| name != Self::DONE) || !data.contains(Self::DONE) {
return false;
}
let Ok(event) = serde_json::from_str::<TranscriptEvent>(data) else {
return false;
};
if event.kind.as_deref() != Some(Self::DONE) {
return false;
}
let Some(usage) = event.usage.as_ref().and_then(transcription_usage) else {
return false;
};
self.usage = Some(usage);
true
}
pub(super) fn snapshot(&self) -> Option<NormalizedUsage> {
self.usage.clone()
}
}
pub(super) fn rerank(body: &[u8]) -> Option<NormalizedUsage> {
let reply: Reply = serde_json::from_slice(body).ok()?;
let usage = reply.usage?;
let input = count(&usage, "prompt_tokens").or_else(|| count(&usage, "total_tokens"));
let units = count(&usage, "search_units");
if input.is_none() && units.is_none() {
return None;
}
let mut normalized = NormalizedUsage {
completeness: UsageCompleteness::Complete,
..NormalizedUsage::default()
};
normalized.tokens.input_tokens = input;
common::metric(&mut normalized, "search_units", units.unwrap_or_default());
Some(normalized)
}
#[cfg(test)]
mod tests {
use super::super::tests::{chunked, feed_sse};
use super::super::{UsageStreamEnd, whole};
use super::*;
use crate::{Dialect, Operation};
use serde_json::json;
fn read(operation: Operation, dialect: Dialect, body: &Value) -> Option<NormalizedUsage> {
whole(operation, dialect, body.to_string().as_bytes())
}
#[test]
fn embeddings_are_input_only() {
let openai = read(
Operation::CreateEmbedding,
Dialect::OpenAi,
&json!({"object": "list", "data": [{"embedding": [0.1]}],
"usage": {"prompt_tokens": 8, "total_tokens": 8}}),
)
.unwrap();
assert_eq!(openai.tokens.input_tokens, Some(8));
assert_eq!(openai.tokens.output_tokens, Some(0));
let gemini = read(
Operation::BatchCreateEmbedding,
Dialect::Gemini,
&json!({"embeddings": [], "usageMetadata": {"promptTokenCount": 6}}),
)
.unwrap();
assert_eq!(gemini.tokens.input_tokens, Some(6));
assert_eq!(gemini.tokens.output_tokens, None);
assert!(
read(
Operation::CreateEmbedding,
Dialect::OpenAi,
&json!({"data": []})
)
.is_none()
);
}
#[test]
fn an_image_reply_counts_images_and_image_tokens() {
let usage = read(
Operation::CreateImage,
Dialect::OpenAi,
&json!({"data": [{"b64_json": "x"}, {"b64_json": "y"}], "quality": "high", "size": "1024x1024",
"usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28,
"input_tokens_details": {"text_tokens": 5, "image_tokens": 15}}}),
)
.unwrap();
assert_eq!(usage.tokens.input_tokens, Some(20));
assert_eq!(usage.tokens.output_tokens, Some(8), "the total stays whole");
assert_eq!(usage.metrics["image_output_tokens"], 8.into());
assert_eq!(usage.metrics["image_input_tokens"], 15.into());
assert_eq!(usage.metrics["image_outputs"], 2.into());
assert_eq!(usage.dimensions[common::MODALITIES_IN_TOTALS], "true");
assert_eq!(usage.dimensions["quality"], "high");
let untokened = read(
Operation::EditImage,
Dialect::OpenAi,
&json!({"data": [{"url": "https://x"}]}),
)
.unwrap();
assert_eq!(untokened.metrics["image_outputs"], 1.into());
assert_eq!(untokened.tokens.input_tokens, None);
assert!(
read(
Operation::CreateImage,
Dialect::OpenAi,
&json!({"data": []})
)
.is_none()
);
assert!(whole(Operation::CreateImage, Dialect::OpenAi, b"{\"data\":[").is_none());
}
#[test]
fn a_streamed_image_counts_its_completed_event_once() {
let completed = json!({"type": "image_generation.completed", "generation_id": "g1",
"b64_json": "QUJD", "size": "1024x1024", "quality": "high", "output_format": "png",
"usage": {"input_tokens": 20, "output_tokens": 7,
"input_tokens_details": {"text_tokens": 5, "image_tokens": 15},
"output_tokens_details": {"image_tokens": 7}}});
let partial = json!({"type": "image_generation.partial_image", "b64_json": "QQ==",
"usage": {"input_tokens": 10, "output_tokens": 5}});
let wire = format!(
"event: image_generation.partial_image\ndata: {partial}\n\nevent: image_generation.completed\ndata: {completed}\n\ndata: {completed}\n\n"
);
for split in [1, 9, usize::MAX] {
let usage = feed_sse(
Operation::CreateImage,
Dialect::OpenAi,
&chunked(&wire, split),
UsageStreamEnd::Complete,
)
.unwrap();
assert_eq!(usage.tokens.input_tokens, Some(20));
assert_eq!(usage.metrics["image_outputs"], 1.into());
assert_eq!(usage.metrics["image_output_tokens"], 7.into());
assert_eq!(usage.dimensions["output_format"], "png");
}
let only_partial = format!("data: {partial}\n\n");
assert!(
feed_sse(
Operation::CreateImage,
Dialect::OpenAi,
&chunked(&only_partial, 4),
UsageStreamEnd::Interrupted,
)
.is_none(),
"a partial image is not an output"
);
}
#[test]
fn a_transcription_reports_tokens_or_seconds() {
let tokens = read(
Operation::CreateTranscription,
Dialect::OpenAi,
&json!({"text": "hi", "usage": {"type": "tokens", "input_tokens": 14, "output_tokens": 45,
"total_tokens": 59, "input_token_details": {"text_tokens": 0, "audio_tokens": 14}}}),
)
.unwrap();
assert_eq!(tokens.tokens.input_tokens, Some(14));
assert_eq!(tokens.metrics["audio_input_tokens"], 14.into());
let seconds = read(
Operation::CreateTranslation,
Dialect::OpenAi,
&json!({"text": "hi", "usage": {"type": "duration", "seconds": 3.5}}),
)
.unwrap();
assert_eq!(seconds.metrics["audio_seconds"], "3.5".parse().unwrap());
assert_eq!(seconds.tokens.input_tokens, None);
let stream = concat!(
"data: {\"type\":\"transcript.text.delta\",\"delta\":\"transcript.text.done\"}\n\n",
"data: {\"type\":\"transcript.text.done\",\"text\":\"hi\",\"usage\":{\"type\":\"tokens\",\"input_tokens\":3,\"output_tokens\":2}}\n\n",
);
let streamed = feed_sse(
Operation::CreateTranscription,
Dialect::OpenAi,
&chunked(stream, 6),
UsageStreamEnd::Complete,
)
.unwrap();
assert_eq!(streamed.tokens.output_tokens, Some(2));
}
#[test]
fn a_rerank_reports_input_and_search_units() {
let tokens = read(
Operation::Rerank,
Dialect::OpenAi,
&json!({"results": [], "usage": {"total_tokens": 31}}),
)
.unwrap();
assert_eq!(tokens.tokens.input_tokens, Some(31));
let units = read(
Operation::Rerank,
Dialect::OpenAi,
&json!({"results": [], "usage": {"search_units": 1}}),
)
.unwrap();
assert_eq!(units.metrics["search_units"], 1.into());
assert!(read(Operation::Rerank, Dialect::OpenAi, &json!({"usage": {}})).is_none());
}
}