Merge branch 'merge-pr-483'

This commit is contained in:
fawney19
2026-05-19 02:28:43 +08:00
53 changed files with 8081 additions and 305 deletions

View File

@@ -1,9 +1,15 @@
use std::collections::BTreeSet;
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use base64::Engine as _;
use serde_json::Value;
use serde_json::{Map, Value};
use crate::contracts::OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND;
use crate::formats::openai::responses::codex::CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT;
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
use crate::formats::shared::stream_core::common::{
build_openai_chat_chunk, build_openai_chat_finish_chunk, build_openai_chat_usage_chunk,
};
use crate::formats::shared::AiSurfaceFinalizeError;
#[derive(Default)]
@@ -20,6 +26,38 @@ struct OpenAiImageFrame {
b64_json: String,
}
#[derive(Default)]
pub struct OpenAiImageChatStreamState {
buffered: Vec<u8>,
response_id: Option<String>,
model: Option<String>,
latest_image: Option<OpenAiImageChatFrame>,
emitted_image_count: u64,
emitted_image_keys: BTreeSet<String>,
started: bool,
finished: bool,
emitted_failure: bool,
}
#[derive(Clone)]
struct OpenAiImageChatFrame {
b64_json: String,
output_format: Option<String>,
}
#[derive(Default)]
pub struct OpenAiImageStreamTerminalState {
event_name: Option<String>,
data_lines: Vec<String>,
response_id: Option<String>,
model: Option<String>,
image_count: u64,
image_keys: BTreeSet<String>,
usage: Option<Value>,
observed_finish: bool,
parser_error: Option<String>,
}
impl OpenAiImageStreamState {
pub fn push_chunk(
&mut self,
@@ -229,6 +267,662 @@ impl OpenAiImageStreamState {
}
}
impl OpenAiImageChatStreamState {
pub fn push_chunk(
&mut self,
report_context: &Value,
chunk: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(block_end) = find_sse_block_end(&self.buffered) {
let block = self.buffered.drain(..block_end).collect::<Vec<_>>();
output.extend(self.transform_block(report_context, &block)?);
drain_sse_separator(&mut self.buffered);
}
Ok(output)
}
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = if self.buffered.is_empty() {
Vec::new()
} else {
let block = std::mem::take(&mut self.buffered);
self.transform_block(report_context, &block)?
};
if !self.finished && !self.emitted_failure && self.latest_image.is_some() {
output.extend(self.emit_final(report_context, None)?);
}
Ok(output)
}
fn transform_block(
&mut self,
report_context: &Value,
block: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let text = std::str::from_utf8(block)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
let mut event_name = None::<String>;
let mut data_lines = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim_end_matches('\r');
if let Some(value) = line.strip_prefix("event:") {
event_name = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
}
let data = data_lines.join("\n");
if data.is_empty() || data == "[DONE]" {
return Ok(Vec::new());
}
let event: Value = serde_json::from_str(&data)?;
let event_type = event
.get("type")
.and_then(Value::as_str)
.or(event_name.as_deref())
.unwrap_or_default();
match event_type {
"error" | "response.failed" | "image_generation.failed" | "image_edit.failed" => {
self.handle_failed(report_context, &event)
}
"response.image_generation_call.partial_image" => {
self.emit_empty_progress_chunk(report_context)
}
"response.output_item.done" => self.handle_output_item_done(report_context, &event),
"response.completed" | "response.done" => self.handle_completed(report_context, &event),
"image_generation.completed" | "image_edit.completed" => {
self.handle_image_completed(report_context, &event)
}
_ => Ok(Vec::new()),
}
}
fn handle_output_item_done(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.finished || self.emitted_failure {
return Ok(Vec::new());
}
let Some(item) = event.get("item").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
return Ok(Vec::new());
}
if let Some(result) = item.get("result").and_then(Value::as_str).map(str::trim) {
if !result.is_empty() {
let key = image_chat_output_key(item, result);
if self.emitted_image_keys.insert(key) {
self.latest_image = Some(OpenAiImageChatFrame {
b64_json: result.to_string(),
output_format: item
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
});
self.emitted_image_count = self.emitted_image_count.saturating_add(1);
}
}
}
self.ensure_started(report_context)
}
fn handle_completed(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.finished || self.emitted_failure {
return Ok(Vec::new());
}
if let Some(response) = event.get("response") {
self.update_identity_from_response(response);
if self.latest_image.is_none() {
if let Some(frame) = completed_response_image_chat_frame(response) {
self.latest_image = Some(frame);
self.emitted_image_count = self.emitted_image_count.saturating_add(1);
}
}
}
let usage = event
.get("response")
.and_then(Value::as_object)
.and_then(|response| {
response
.get("tool_usage")
.and_then(|value| value.get("image_gen"))
.cloned()
.or_else(|| response.get("usage").cloned())
});
self.emit_final(report_context, usage.as_ref())
}
fn handle_image_completed(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.finished || self.emitted_failure {
return Ok(Vec::new());
}
if let Some(result) = event
.get("b64_json")
.or_else(|| event.get("result"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.latest_image = Some(OpenAiImageChatFrame {
b64_json: result.to_string(),
output_format: event
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
});
self.emitted_image_count = self.emitted_image_count.max(1);
}
self.emit_final(report_context, event.get("usage"))
}
fn handle_failed(
&mut self,
_report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.emitted_failure {
return Ok(Vec::new());
}
self.emitted_failure = true;
self.finished = true;
let mut output = encode_json_sse(
None,
&serde_json::json!({
"error": image_failure_error(event),
}),
)?;
output.extend(encode_done_sse());
Ok(output)
}
fn ensure_started(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.started {
return Ok(Vec::new());
}
self.emit_empty_progress_chunk(report_context)
}
fn emit_empty_progress_chunk(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.started = true;
let (response_id, model) = self.identity(report_context);
encode_json_sse(
None,
&build_openai_chat_chunk(&response_id, &model, String::new(), None, None),
)
}
fn emit_final(
&mut self,
report_context: &Value,
usage: Option<&Value>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.finished || self.emitted_failure {
return Ok(Vec::new());
}
let Some(latest_image) = self.latest_image.clone() else {
return self.ensure_started(report_context);
};
let mut output = self.ensure_started(report_context)?;
let (response_id, model) = self.identity(report_context);
output.extend(encode_json_sse(
None,
&build_openai_chat_chunk(
&response_id,
&model,
image_chat_markdown(&latest_image),
None,
None,
),
)?);
output.extend(encode_json_sse(
None,
&build_openai_chat_finish_chunk(&response_id, &model, Some("stop")),
)?);
if let Some((input_tokens, output_tokens, total_tokens, reasoning_tokens)) =
openai_image_chat_usage_counts(usage)
{
output.extend(encode_json_sse(
None,
&build_openai_chat_usage_chunk(
&response_id,
&model,
input_tokens,
output_tokens,
total_tokens,
reasoning_tokens,
),
)?);
}
output.extend(encode_done_sse());
self.finished = true;
Ok(output)
}
fn update_identity_from_response(&mut self, response: &Value) {
if let Some(id) = response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.response_id = Some(id.replace("resp", "chatcmpl"));
}
if let Some(model) = response
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.model = Some(model.to_string());
}
}
fn identity(&self, report_context: &Value) -> (String, String) {
let response_id = self.response_id.clone().unwrap_or_else(|| {
report_context
.get("request_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("chatcmpl-image-{value}"))
.unwrap_or_else(|| "chatcmpl-image".to_string())
});
let model = self
.model
.clone()
.or_else(|| {
report_context
.get("mapped_model")
.or_else(|| report_context.get("model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "gpt-image".to_string());
(response_id, model)
}
}
impl OpenAiImageStreamTerminalState {
pub fn push_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Option<ExecutionStreamTerminalSummary>, AiSurfaceFinalizeError> {
let text = std::str::from_utf8(&line)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
let trimmed = text.trim_matches('\r').trim_matches('\n');
if trimmed.is_empty() {
self.flush_event(report_context)?;
return Ok(self.latest_summary(report_context));
}
if let Some(value) = trimmed.strip_prefix("event:") {
self.event_name = Some(value.trim().to_string());
} else if let Some(value) = trimmed.strip_prefix("data:") {
self.data_lines.push(value.trim().to_string());
}
Ok(self.latest_summary(report_context))
}
pub fn finish(
&mut self,
report_context: &Value,
) -> Result<Option<ExecutionStreamTerminalSummary>, AiSurfaceFinalizeError> {
self.flush_event(report_context)?;
if self.image_count > 0 && !self.observed_finish {
self.observed_finish = true;
}
Ok(self.latest_summary(report_context))
}
fn flush_event(&mut self, report_context: &Value) -> Result<(), AiSurfaceFinalizeError> {
if self.data_lines.is_empty() {
self.event_name = None;
return Ok(());
}
let data = std::mem::take(&mut self.data_lines).join("\n");
let event_name = self.event_name.take();
if data.is_empty() || data == "[DONE]" {
return Ok(());
}
let event = match serde_json::from_str::<Value>(&data) {
Ok(event) => event,
Err(err) => {
self.parser_error.get_or_insert_with(|| err.to_string());
return Ok(());
}
};
let event_type = event
.get("type")
.and_then(Value::as_str)
.or(event_name.as_deref())
.unwrap_or_default();
match event_type {
"response.output_item.done" => self.observe_output_item_done(&event),
"response.completed" | "response.done" => self.observe_completed(&event),
"image_generation.completed" | "image_edit.completed" => {
self.observe_image_completed(&event)
}
"error" | "response.failed" | "image_generation.failed" | "image_edit.failed" => {
self.parser_error
.get_or_insert_with(|| image_failure_error(&event).to_string());
self.observed_finish = true;
}
_ => {}
}
if self.model.is_none() {
self.model = image_bridge_model(Some(report_context));
}
Ok(())
}
fn observe_output_item_done(&mut self, event: &Value) {
let Some(item) = event.get("item").and_then(Value::as_object) else {
return;
};
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
return;
}
let Some(result) = item
.get("result")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let key = image_chat_output_key(item, result);
if self.image_keys.insert(key) {
self.image_count = self.image_count.saturating_add(1);
}
}
fn observe_completed(&mut self, event: &Value) {
self.observed_finish = true;
let Some(response) = event.get("response") else {
return;
};
self.update_identity_from_response(response);
if self.image_count == 0 {
self.image_count = completed_response_image_count(response);
}
self.usage = response
.get("tool_usage")
.and_then(|value| value.get("image_gen"))
.cloned()
.or_else(|| response.get("usage").cloned())
.or_else(|| self.usage.clone());
}
fn observe_image_completed(&mut self, event: &Value) {
self.observed_finish = true;
if self.image_count == 0 {
if event
.get("b64_json")
.or_else(|| event.get("result"))
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
self.image_count = 1;
}
}
self.usage = event.get("usage").cloned().or_else(|| self.usage.clone());
}
fn update_identity_from_response(&mut self, response: &Value) {
if let Some(id) = response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.response_id = Some(id.to_string());
}
if let Some(model) = response
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.model = Some(model.to_string());
}
}
fn latest_summary(&self, report_context: &Value) -> Option<ExecutionStreamTerminalSummary> {
if self.image_count == 0
&& self.usage.is_none()
&& self.response_id.is_none()
&& self.model.is_none()
&& self.parser_error.is_none()
{
return None;
}
Some(ExecutionStreamTerminalSummary {
standardized_usage: openai_image_stream_standardized_usage(
self.usage.as_ref(),
Some(report_context),
self.image_count,
),
finish_reason: self.observed_finish.then(|| "stop".to_string()),
response_id: self.response_id.clone(),
model: self
.model
.clone()
.or_else(|| image_bridge_model(Some(report_context))),
observed_finish: self.observed_finish,
unknown_event_count: 0,
parser_error: self.parser_error.clone(),
})
}
}
fn completed_response_image_chat_frame(response: &Value) -> Option<OpenAiImageChatFrame> {
response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("image_generation_call"))
.find_map(|item| {
let result = item.get("result").and_then(Value::as_str)?.trim();
if result.is_empty() {
return None;
}
Some(OpenAiImageChatFrame {
b64_json: result.to_string(),
output_format: item
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
})
}
fn completed_response_image_count(response: &Value) -> u64 {
response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("image_generation_call"))
.filter(|item| {
item.get("result")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
.count() as u64
}
fn image_chat_output_key(item: &Map<String, Value>, result: &str) -> String {
item.get("id")
.or_else(|| item.get("call_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| result.to_string())
}
fn openai_image_stream_standardized_usage(
usage: Option<&Value>,
report_context: Option<&Value>,
image_count: u64,
) -> Option<StandardizedUsage> {
let mut standardized_usage = usage
.and_then(openai_image_usage_to_standardized_usage)
.unwrap_or_else(StandardizedUsage::new);
if image_count > 0 {
standardized_usage.request_count = i64::try_from(image_count).unwrap_or(i64::MAX);
standardized_usage
.dimensions
.insert("image_count".to_string(), serde_json::json!(image_count));
}
if let Some(output_format) = image_request_output_format(report_context) {
standardized_usage.dimensions.insert(
"image_output_format".to_string(),
serde_json::json!(output_format),
);
}
if let Some(size) = image_request_size(report_context) {
standardized_usage
.dimensions
.insert("image_size".to_string(), serde_json::json!(size));
}
if let Some(quality) = image_request_quality(report_context) {
standardized_usage
.dimensions
.insert("image_quality".to_string(), serde_json::json!(quality));
}
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
}
fn openai_image_usage_to_standardized_usage(value: &Value) -> Option<StandardizedUsage> {
let usage = value.as_object()?;
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(Value::as_i64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
let mut standardized_usage = StandardizedUsage::new();
standardized_usage.input_tokens = input_tokens;
standardized_usage.output_tokens = output_tokens;
standardized_usage.cache_creation_tokens = cache_creation_tokens;
standardized_usage.cache_read_tokens = cache_read_tokens;
standardized_usage
.dimensions
.insert("total_tokens".to_string(), serde_json::json!(total_tokens));
Some(standardized_usage.normalize_cache_creation_breakdown())
}
fn image_chat_markdown(frame: &OpenAiImageChatFrame) -> String {
let mime_type = match frame
.output_format
.as_deref()
.unwrap_or("png")
.trim()
.to_ascii_lowercase()
.as_str()
{
"jpg" | "jpeg" => "image/jpeg".to_string(),
"webp" => "image/webp".to_string(),
"png" => "image/png".to_string(),
value if !value.is_empty() => format!("image/{value}"),
_ => "image/png".to_string(),
};
format!(
"![generated image](data:{mime_type};base64,{})",
frame.b64_json
)
}
fn openai_image_chat_usage_counts(usage: Option<&Value>) -> Option<(u64, u64, u64, u64)> {
let usage = usage.and_then(Value::as_object)?;
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or(input_tokens.saturating_add(output_tokens));
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
(total_tokens > 0).then_some((input_tokens, output_tokens, total_tokens, 0))
}
fn image_failure_error(event: &Value) -> Value {
let mut error = event
.get("error")
@@ -340,6 +1034,48 @@ fn image_request_operation(report_context: &Value) -> Option<&str> {
.filter(|value| !value.is_empty())
}
fn image_request_output_format(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("output_format"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_request_size(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("size"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("quality"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
report_context.and_then(|context| {
context
.get("mapped_model")
.or_else(|| context.get("model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn find_sse_block_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(2)

View File

@@ -92,8 +92,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
let total_tokens = usage.get("total_tokens").and_then(Value::as_u64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
.saturating_add(reasoning_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
@@ -150,8 +149,7 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
output_tokens,
total_tokens: input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
.saturating_add(reasoning_tokens),
cache_creation_tokens,
cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens,
@@ -235,7 +233,7 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
.unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_read_tokens),
.saturating_add(reasoning_tokens),
);
Some(CanonicalUsage {
input_tokens,

View File

@@ -8,6 +8,7 @@ use crate::formats::openai::chat::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState,
};
use crate::formats::openai::image::stream::OpenAiImageStreamTerminalState;
use crate::formats::shared::error_body::{
build_core_error_body_for_client_format, LocalCoreSyncErrorKind,
};
@@ -97,7 +98,7 @@ impl StreamingStandardFormatMatrix {
#[derive(Default)]
pub struct StreamingStandardTerminalObserver {
provider: Option<ProviderStreamParser>,
provider: Option<TerminalStreamParser>,
latest_summary: Option<ExecutionStreamTerminalSummary>,
}
@@ -111,8 +112,17 @@ impl StreamingStandardTerminalObserver {
let Some(provider) = self.provider.as_mut() else {
return Ok(());
};
let frames = provider.push_line(report_context, line)?;
self.observe_frames(frames);
match provider {
TerminalStreamParser::Standard(provider) => {
let frames = provider.push_line(report_context, line)?;
self.observe_frames(frames);
}
TerminalStreamParser::OpenAIImage(provider) => {
if let Some(summary) = provider.push_line(report_context, line)? {
self.latest_summary = Some(summary);
}
}
}
Ok(())
}
@@ -124,8 +134,17 @@ impl StreamingStandardTerminalObserver {
let Some(provider) = self.provider.as_mut() else {
return Ok(self.latest_summary.clone());
};
let frames = provider.finish(report_context)?;
self.observe_frames(frames);
match provider {
TerminalStreamParser::Standard(provider) => {
let frames = provider.finish(report_context)?;
self.observe_frames(frames);
}
TerminalStreamParser::OpenAIImage(provider) => {
if let Some(summary) = provider.finish(report_context)? {
self.latest_summary = Some(summary);
}
}
}
Ok(self.latest_summary.clone())
}
@@ -153,7 +172,7 @@ impl StreamingStandardTerminalObserver {
return;
}
let provider_api_format = provider_api_format_for_context(report_context);
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
self.provider = TerminalStreamParser::for_api_format(provider_api_format.as_str());
}
fn observe_frames(&mut self, frames: Vec<CanonicalStreamFrame>) {
@@ -194,6 +213,23 @@ impl StreamingStandardTerminalObserver {
}
}
enum TerminalStreamParser {
Standard(ProviderStreamParser),
OpenAIImage(OpenAiImageStreamTerminalState),
}
impl TerminalStreamParser {
fn for_api_format(provider_api_format: &str) -> Option<Self> {
if provider_api_format
.trim()
.eq_ignore_ascii_case("openai:image")
{
return Some(Self::OpenAIImage(OpenAiImageStreamTerminalState::default()));
}
ProviderStreamParser::for_api_format(provider_api_format).map(Self::Standard)
}
}
enum ProviderStreamParser {
OpenAIChat(OpenAIChatProviderState),
OpenAIResponses(OpenAIResponsesProviderState),
@@ -936,4 +972,86 @@ mod tests {
assert_eq!(summary.unknown_event_count, 1);
assert!(!summary.observed_finish);
}
#[test]
fn terminal_observer_tracks_openai_image_stream_usage() {
let mut report_context = report_context("openai:image", "openai:chat");
report_context["image_request"] = json!({
"size": "1024x1024",
"quality": "medium",
"output_format": "png",
});
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.output_item.done",
"output_index": 0,
"item": {
"id": "ig_123",
"type": "image_generation_call",
"result": "aGVsbG8=",
},
})),
)
.expect("image output item should parse");
observer
.push_line(&report_context, b"\n".to_vec())
.expect("image output event should flush");
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_image_123",
"model": "gpt-image-2",
"output": [],
"tool_usage": {
"image_gen": {
"input_tokens": 40,
"output_tokens": 60,
"total_tokens": 100,
},
},
},
})),
)
.expect("image completed should parse");
observer
.push_line(&report_context, b"\n".to_vec())
.expect("image completed event should flush");
let summary = observer
.finish(&report_context)
.expect("image summary should finish")
.expect("summary should exist");
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
assert_eq!(summary.response_id.as_deref(), Some("resp_image_123"));
assert_eq!(summary.model.as_deref(), Some("gpt-image-2"));
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
assert!(summary.observed_finish);
assert_eq!(usage.input_tokens, 40);
assert_eq!(usage.output_tokens, 60);
assert_eq!(usage.request_count, 1);
assert_eq!(usage.dimensions.get("image_count"), Some(&json!(1)));
assert_eq!(usage.dimensions.get("total_tokens"), Some(&json!(100)));
assert_eq!(
usage.dimensions.get("image_size"),
Some(&json!("1024x1024"))
);
assert_eq!(
usage.dimensions.get("image_output_format"),
Some(&json!("png"))
);
assert_eq!(
usage.dimensions.get("image_quality"),
Some(&json!("medium"))
);
}
}

View File

@@ -1,6 +1,6 @@
use serde_json::Value;
use crate::formats::openai::image::stream::OpenAiImageStreamState;
use crate::formats::openai::image::stream::{OpenAiImageChatStreamState, OpenAiImageStreamState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::stream_core::StreamingStandardFormatMatrix;
use crate::formats::shared::AiSurfaceFinalizeError;
@@ -15,6 +15,7 @@ pub enum FinalizeStreamRewriteMode {
EnvelopeUnwrap,
ModelDirectiveDisplay,
OpenAiImage,
OpenAiImageToOpenAiChat,
Standard,
KiroToClaudeCli,
KiroToClaudeCliThenStandard,
@@ -57,6 +58,14 @@ pub fn resolve_finalize_stream_rewrite_mode(
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard);
}
if provider_api_format == "openai:image" && client_api_format == "openai:chat" {
return Some(FinalizeStreamRewriteMode::OpenAiImageToOpenAiChat);
}
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
return Some(FinalizeStreamRewriteMode::OpenAiImage);
}
if needs_conversion {
// CPA strategy: when provider and client share the same wire format
// (exact match or same family), pass through the stream verbatim.
@@ -73,10 +82,6 @@ pub fn resolve_finalize_stream_rewrite_mode(
.then_some(FinalizeStreamRewriteMode::Standard);
}
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
return Some(FinalizeStreamRewriteMode::OpenAiImage);
}
if envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME) {
return (provider_api_format == "claude:messages"
&& client_api_format == "claude:messages")
@@ -106,6 +111,7 @@ enum AiSurfaceStreamRewriteState {
EnvelopeUnwrap,
ModelDirectiveDisplay,
OpenAiImage(Box<OpenAiImageStreamState>),
OpenAiImageToOpenAiChat(Box<OpenAiImageChatStreamState>),
Standard(Box<StreamingStandardFormatMatrix>),
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
KiroToClaudeCliThenStandard {
@@ -132,6 +138,11 @@ pub fn maybe_build_ai_surface_stream_rewriter<'a>(
FinalizeStreamRewriteMode::OpenAiImage => {
AiSurfaceStreamRewriteState::OpenAiImage(Box::<OpenAiImageStreamState>::default())
}
FinalizeStreamRewriteMode::OpenAiImageToOpenAiChat => {
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(
Box::<OpenAiImageChatStreamState>::default(),
)
}
FinalizeStreamRewriteMode::Standard => {
AiSurfaceStreamRewriteState::Standard(Box::<StreamingStandardFormatMatrix>::default())
}
@@ -159,6 +170,9 @@ impl AiSurfaceStreamRewriter<'_> {
AiSurfaceStreamRewriteState::OpenAiImage(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
}
@@ -183,6 +197,9 @@ impl AiSurfaceStreamRewriter<'_> {
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::OpenAiImage(state) => state.finish(self.report_context),
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.finish(self.report_context)
}
@@ -228,6 +245,7 @@ impl AiSurfaceStreamRewriter<'_> {
transform_standard_line(state, self.report_context, line)
}
AiSurfaceStreamRewriteState::OpenAiImage(_)
| AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCli(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
}
@@ -686,4 +704,58 @@ data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"thinki
Some(FinalizeStreamRewriteMode::OpenAiImage)
);
}
#[test]
fn rewrites_openai_image_stream_to_openai_chat_final_chunk() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:chat",
"mapped_model": "gpt-image-2",
"request_id": "trace-image-chat-stream",
"needs_conversion": false,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::OpenAiImageToOpenAiChat)
);
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("image to chat stream rewriter should exist");
let progress = rewriter
.push_chunk(
br#"event: response.image_generation_call.partial_image
data: {"type":"response.image_generation_call.partial_image","partial_image_b64":"cGFydGlhbA=="}
"#,
)
.expect("partial image should rewrite as progress");
let progress_text = String::from_utf8(progress).expect("progress output should be utf8");
assert!(progress_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(!progress_text.contains("cGFydGlhbA=="));
let output_item = rewriter
.push_chunk(
br#"event: response.output_item.done
data: {"type":"response.output_item.done","item":{"type":"image_generation_call","id":"ig_1","result":"aGVsbG8=","output_format":"png"}}
"#,
)
.expect("output item should rewrite");
let output_item_text = String::from_utf8(output_item).expect("output item should be utf8");
assert!(output_item_text.is_empty());
let final_output = rewriter
.push_chunk(
br#"event: response.completed
data: {"type":"response.completed","response":{"id":"resp_123","model":"gpt-image-2","tool_usage":{"image_gen":{"total_tokens":0}},"output":[]}}
"#,
)
.expect("completed event should rewrite");
let final_text = String::from_utf8(final_output).expect("final output should be utf8");
assert!(final_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(final_text.contains("![generated image](data:image/png;base64,aGVsbG8=)"));
assert!(final_text.contains("data: [DONE]"));
assert!(!final_text.contains("image_generation.completed"));
}
}

View File

@@ -1,16 +1,21 @@
use std::borrow::Cow;
use aether_ai_formats::formats::conversion::response::{
convert_claude_response_to_openai_responses, convert_gemini_response_to_openai_responses,
convert_openai_chat_response_to_openai_responses,
};
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
use crate::formats::gemini::generate_content::stream::GeminiClientEmitter;
use crate::formats::openai::chat::stream::{
OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
};
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
use crate::formats::shared::stream_core::common::{
build_openai_chat_chunk, build_openai_chat_finish_chunk, build_openai_chat_usage_chunk,
};
use crate::formats::shared::stream_core::CanonicalStreamFrame;
use crate::formats::shared::AiSurfaceFinalizeError;
@@ -27,12 +32,25 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let provider_api_format = normalize_api_format(provider_api_format);
let client_api_format = normalize_api_format(client_api_format);
if client_api_format == "openai:image"
&& matches!(
provider_api_format.as_str(),
"openai:image" | "gemini:generate_content"
)
{
if provider_api_format == "openai:image" {
return match client_api_format.as_str() {
"openai:image" => {
maybe_bridge_openai_image_sync_json_to_stream(provider_body_json, report_context)
}
"openai:chat" => maybe_bridge_openai_image_sync_json_to_chat_stream(
provider_body_json,
report_context,
),
"openai:responses" | "openai:responses:compact" => {
maybe_bridge_openai_image_sync_json_to_responses_stream(
provider_body_json,
report_context,
)
}
_ => Ok(None),
};
}
if client_api_format == "openai:image" && provider_api_format == "gemini:generate_content" {
return maybe_bridge_openai_image_sync_json_to_stream(provider_body_json, report_context);
}
if !is_standard_api_format(provider_api_format.as_str())
@@ -72,49 +90,19 @@ fn maybe_bridge_openai_image_sync_json_to_stream(
provider_body_json: &Value,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let provider_api_format = report_context
.and_then(|value| value.get("provider_api_format"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("openai:image");
let owned_response;
let provider_body_json = if provider_api_format == "gemini:generate_content" {
let Some(converted) =
crate::formats::shared::image_bridge::build_openai_image_response_from_gemini_response(
provider_body_json,
report_context,
)
else {
return Ok(None);
};
owned_response = converted;
&owned_response
} else if provider_body_json.get("output").is_some() && provider_body_json.get("data").is_none()
{
let Some(converted) = crate::formats::shared::image_bridge::build_openai_image_response_from_response_stream_sync_body(
provider_body_json,
report_context,
) else {
return Ok(None);
};
owned_response = converted;
&owned_response
} else {
provider_body_json
};
let Some(response) = provider_body_json.as_object() else {
return Ok(None);
};
let Some(image) = response
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.find_map(extract_openai_image_sync_b64_json)
let Some(provider_body_json) =
normalize_openai_image_sync_response(provider_body_json, report_context)?
else {
return Ok(None);
};
let Some(response) = provider_body_json.as_ref().as_object() else {
return Ok(None);
};
let outputs = collect_openai_image_outputs(response, report_context);
let Some(image) = outputs.iter().find_map(OpenAiImageOutput::b64_json) else {
return Ok(None);
};
let image_count = openai_image_response_image_count(response).max(outputs.len() as u64);
let usage = response.get("usage").cloned().unwrap_or(Value::Null);
let event_name = openai_image_completed_event_name(report_context);
let sse_body = encode_json_sse(
@@ -128,27 +116,461 @@ fn maybe_bridge_openai_image_sync_json_to_stream(
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: Some(ExecutionStreamTerminalSummary {
standardized_usage: response
.get("usage")
.and_then(standardized_usage_from_openai_usage),
finish_reason: Some("stop".to_string()),
response_id: response
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
model: response
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| image_bridge_model(report_context)),
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
}),
terminal_summary: Some(openai_image_terminal_summary(
response,
report_context,
image_count,
)),
}))
}
fn maybe_bridge_openai_image_sync_json_to_chat_stream(
provider_body_json: &Value,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let Some(provider_body_json) =
normalize_openai_image_sync_response(provider_body_json, report_context)?
else {
return Ok(None);
};
let Some(response) = provider_body_json.as_ref().as_object() else {
return Ok(None);
};
let outputs = collect_openai_image_outputs(response, report_context);
if outputs.is_empty() {
return Ok(None);
}
let image_count = openai_image_response_image_count(response).max(outputs.len() as u64);
let summary = openai_image_terminal_summary(response, report_context, image_count);
let response_id = openai_image_bridge_response_id(response, report_context, "chatcmpl-image");
let model = openai_image_bridge_response_model(response, report_context);
let content = outputs
.iter()
.enumerate()
.map(|(index, output)| output.markdown(index))
.collect::<Vec<_>>()
.join("\n\n");
let mut sse_body = Vec::new();
sse_body.extend(encode_json_sse(
None,
&build_openai_chat_chunk(&response_id, &model, content, None, None),
)?);
sse_body.extend(encode_json_sse(
None,
&build_openai_chat_finish_chunk(&response_id, &model, Some("stop")),
)?);
if let Some((input_tokens, output_tokens, total_tokens, reasoning_tokens)) = summary
.standardized_usage
.as_ref()
.and_then(openai_chat_usage_counts)
{
sse_body.extend(encode_json_sse(
None,
&build_openai_chat_usage_chunk(
&response_id,
&model,
input_tokens,
output_tokens,
total_tokens,
reasoning_tokens,
),
)?);
}
sse_body.extend(encode_done_sse());
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: Some(summary),
}))
}
fn maybe_bridge_openai_image_sync_json_to_responses_stream(
provider_body_json: &Value,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let Some(provider_body_json) =
normalize_openai_image_sync_response(provider_body_json, report_context)?
else {
return Ok(None);
};
let Some(response) = provider_body_json.as_ref().as_object() else {
return Ok(None);
};
let outputs = collect_openai_image_outputs(response, report_context);
if outputs.is_empty() {
return Ok(None);
}
let response_id = openai_image_bridge_response_id(response, report_context, "resp-image");
let model = openai_image_bridge_response_model(response, report_context);
let mut response_output = Vec::new();
for (index, output) in outputs.iter().enumerate() {
response_output.push(output.responses_image_generation_item(&response_id, index));
}
let mut response_object = Map::new();
response_object.insert("id".to_string(), Value::String(response_id.clone()));
response_object.insert("object".to_string(), Value::String("response".to_string()));
response_object.insert("model".to_string(), Value::String(model));
response_object.insert("status".to_string(), Value::String("completed".to_string()));
response_object.insert("output".to_string(), Value::Array(response_output.clone()));
if let Some(created) = response.get("created").and_then(Value::as_i64) {
response_object.insert("created_at".to_string(), json!(created));
}
if let Some(usage) = response.get("usage").filter(|value| value.is_object()) {
response_object.insert("usage".to_string(), usage.clone());
}
let mut sse_body = Vec::new();
for (index, item) in response_output.iter().enumerate() {
sse_body.extend(encode_json_sse(
Some("response.output_item.done"),
&json!({
"type": "response.output_item.done",
"output_index": index,
"item": item,
}),
)?);
}
sse_body.extend(encode_json_sse(
Some("response.completed"),
&json!({
"type": "response.completed",
"response": Value::Object(response_object),
}),
)?);
let image_count = openai_image_response_image_count(response).max(outputs.len() as u64);
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: Some(openai_image_terminal_summary(
response,
report_context,
image_count,
)),
}))
}
#[derive(Clone, Debug)]
struct OpenAiImageOutput {
b64_json: Option<String>,
url: Option<String>,
mime_type: String,
output_format: Option<String>,
revised_prompt: Option<String>,
}
impl OpenAiImageOutput {
fn b64_json(&self) -> Option<String> {
self.b64_json
.clone()
.or_else(|| self.url.as_deref().and_then(extract_base64_from_data_url))
}
fn source_url(&self) -> Option<String> {
self.url.clone().or_else(|| {
self.b64_json
.as_ref()
.map(|value| format!("data:{};base64,{value}", self.mime_type))
})
}
fn markdown(&self, index: usize) -> String {
let alt = if index == 0 {
"generated image".to_string()
} else {
format!("generated image {}", index + 1)
};
match self.source_url() {
Some(url) => format!("![{alt}]({url})"),
None => String::new(),
}
}
fn responses_image_generation_item(&self, response_id: &str, index: usize) -> Value {
let mut item = Map::new();
item.insert(
"id".to_string(),
Value::String(format!("{response_id}_img_{index}")),
);
item.insert(
"type".to_string(),
Value::String("image_generation_call".to_string()),
);
item.insert("status".to_string(), Value::String("completed".to_string()));
if let Some(result) = self.b64_json().or_else(|| self.url.clone()) {
item.insert("result".to_string(), Value::String(result));
}
if let Some(output_format) = self.output_format.as_ref() {
item.insert(
"output_format".to_string(),
Value::String(output_format.clone()),
);
}
if let Some(revised_prompt) = self.revised_prompt.as_ref() {
item.insert(
"revised_prompt".to_string(),
Value::String(revised_prompt.clone()),
);
}
Value::Object(item)
}
}
fn normalize_openai_image_sync_response<'a>(
provider_body_json: &'a Value,
report_context: Option<&Value>,
) -> Result<Option<Cow<'a, Value>>, AiSurfaceFinalizeError> {
let provider_api_format = report_context
.and_then(|value| value.get("provider_api_format"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("openai:image");
if provider_api_format == "gemini:generate_content" {
let Some(converted) =
crate::formats::shared::image_bridge::build_openai_image_response_from_gemini_response(
provider_body_json,
report_context,
)
else {
return Ok(None);
};
return Ok(Some(Cow::Owned(converted)));
}
if provider_body_json.get("output").is_some() && provider_body_json.get("data").is_none() {
let Some(converted) = crate::formats::shared::image_bridge::build_openai_image_response_from_response_stream_sync_body(
provider_body_json,
report_context,
) else {
return Ok(None);
};
return Ok(Some(Cow::Owned(converted)));
}
Ok(Some(Cow::Borrowed(provider_body_json)))
}
fn collect_openai_image_outputs(
response: &Map<String, Value>,
report_context: Option<&Value>,
) -> Vec<OpenAiImageOutput> {
response
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.filter_map(|item| openai_image_output_from_item(item, report_context))
.collect()
}
fn openai_image_output_from_item(
item: &Map<String, Value>,
report_context: Option<&Value>,
) -> Option<OpenAiImageOutput> {
let b64_json = extract_openai_image_sync_b64_json(item);
let url = item
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if b64_json.is_none() && url.is_none() {
return None;
}
let output_format = item
.get("output_format")
.or_else(|| item.get("format"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| image_request_output_format(report_context));
let mime_type = url
.as_deref()
.and_then(extract_mime_type_from_data_url)
.or_else(|| {
output_format
.as_deref()
.map(mime_type_from_image_output_format)
})
.unwrap_or_else(|| "image/png".to_string());
let revised_prompt = item
.get("revised_prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some(OpenAiImageOutput {
b64_json,
url,
mime_type,
output_format,
revised_prompt,
})
}
fn openai_image_response_image_count(response: &Map<String, Value>) -> u64 {
response
.get("data")
.and_then(Value::as_array)
.map(|items| items.len() as u64)
.unwrap_or(0)
}
fn openai_image_terminal_summary(
response: &Map<String, Value>,
report_context: Option<&Value>,
image_count: u64,
) -> ExecutionStreamTerminalSummary {
ExecutionStreamTerminalSummary {
standardized_usage: openai_image_standardized_usage(
response.get("usage"),
report_context,
image_count,
),
finish_reason: Some("stop".to_string()),
response_id: response
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
model: response
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| image_bridge_model(report_context)),
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
}
}
fn openai_image_standardized_usage(
usage: Option<&Value>,
report_context: Option<&Value>,
image_count: u64,
) -> Option<StandardizedUsage> {
let mut standardized_usage = usage
.and_then(standardized_usage_from_openai_usage)
.unwrap_or_else(StandardizedUsage::new);
if image_count > 0 {
standardized_usage.request_count = i64::try_from(image_count).unwrap_or(i64::MAX);
standardized_usage
.dimensions
.insert("image_count".to_string(), json!(image_count));
}
if let Some(output_format) = image_request_output_format(report_context) {
standardized_usage
.dimensions
.insert("image_output_format".to_string(), json!(output_format));
}
if let Some(size) = image_request_size(report_context) {
standardized_usage
.dimensions
.insert("image_size".to_string(), json!(size));
}
if let Some(quality) = image_request_quality(report_context) {
standardized_usage
.dimensions
.insert("image_quality".to_string(), json!(quality));
}
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
}
fn openai_chat_usage_counts(usage: &StandardizedUsage) -> Option<(u64, u64, u64, u64)> {
let input_tokens = usage.input_tokens.max(0) as u64;
let output_tokens = usage.output_tokens.max(0) as u64;
let reasoning_tokens = usage.reasoning_tokens.max(0) as u64;
let total_tokens = usage
.dimensions
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
input_tokens
.saturating_add(output_tokens)
.saturating_add(reasoning_tokens)
});
(total_tokens > 0).then_some((input_tokens, output_tokens, total_tokens, reasoning_tokens))
}
fn openai_image_bridge_response_id(
response: &Map<String, Value>,
report_context: Option<&Value>,
fallback_prefix: &str,
) -> String {
response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
report_context
.and_then(|value| value.get("request_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("{fallback_prefix}-{value}"))
})
.unwrap_or_else(|| fallback_prefix.to_string())
}
fn openai_image_bridge_response_model(
response: &Map<String, Value>,
report_context: Option<&Value>,
) -> String {
response
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| image_bridge_model(report_context))
.unwrap_or_else(|| "gpt-image".to_string())
}
fn image_request_output_format(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("output_format"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_request_size(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("size"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("quality"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn mime_type_from_image_output_format(output_format: &str) -> String {
match output_format.trim().to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"webp" => "image/webp".to_string(),
"png" => "image/png".to_string(),
value if !value.is_empty() => format!("image/{value}"),
_ => "image/png".to_string(),
}
}
fn normalize_api_format(value: &str) -> String {
aether_ai_formats::normalize_api_format_alias(value)
}
@@ -186,6 +608,14 @@ fn extract_base64_from_data_url(value: &str) -> Option<String> {
(!payload.trim().is_empty()).then(|| payload.trim().to_string())
}
fn extract_mime_type_from_data_url(value: &str) -> Option<String> {
let trimmed = value.trim();
let (metadata, _) = trimmed.split_once(',')?;
let mime_type = metadata.strip_prefix("data:")?.strip_suffix(";base64")?;
let mime_type = mime_type.trim();
(!mime_type.is_empty()).then(|| mime_type.to_string())
}
fn openai_image_completed_event_name(report_context: Option<&Value>) -> &'static str {
if openai_image_request_operation(report_context) == Some("edit") {
"image_edit.completed"
@@ -565,6 +995,76 @@ mod tests {
.cloned(),
Some(json!(100))
);
assert_eq!(
summary
.standardized_usage
.as_ref()
.and_then(|usage| usage.dimensions.get("image_count"))
.cloned(),
Some(json!(1))
);
}
#[test]
fn bridges_openai_image_sync_json_to_openai_chat_sse() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:chat",
"mapped_model": "gpt-image-2",
"image_request": {
"operation": "generate",
"output_format": "png",
"size": "1024x1024",
"quality": "medium"
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"id": "img_123",
"created": 1776971267,
"model": "gpt-image-2",
"data": [
{"b64_json": "aGVsbG8="},
{"b64_json": "d29ybGQ="}
],
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50
}
}),
"openai:image",
"openai:chat",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let output = utf8(outcome.sse_body);
assert!(output.contains("\"object\":\"chat.completion.chunk\""));
assert!(output.contains("![generated image](data:image/png;base64,aGVsbG8=)"));
assert!(output.contains("![generated image 2](data:image/png;base64,d29ybGQ=)"));
assert!(output.contains("\"finish_reason\":\"stop\""));
assert!(output.contains("data: [DONE]"));
assert!(!output.contains("image_generation.completed"));
let summary = outcome
.terminal_summary
.expect("terminal summary should exist");
let usage = summary
.standardized_usage
.as_ref()
.expect("standard usage should exist");
assert_eq!(usage.request_count, 2);
assert_eq!(usage.dimensions.get("image_count"), Some(&json!(2)));
assert_eq!(
usage.dimensions.get("image_size"),
Some(&json!("1024x1024"))
);
assert_eq!(
usage.dimensions.get("image_quality"),
Some(&json!("medium"))
);
}
#[test]

View File

@@ -23,14 +23,28 @@ impl DefaultBillingRuleGenerator {
pricing: &BillingModelPricingSnapshot,
task_type: &str,
) -> Option<VirtualBillingRule> {
let pricing_config = pricing.effective_tiered_pricing();
let tiers = pricing
.effective_tiered_pricing()
.and_then(|value| value.get("tiers"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let explicit_image_output_price_default =
explicit_image_output_price_default(pricing_config);
let image_output_price_default = explicit_image_output_price_default.unwrap_or(0.0);
let has_image_output_matrix = explicit_image_output_price_entries(pricing_config)
.is_some_and(|entries| !entries.is_empty());
let has_image_output_ranges = explicit_image_output_price_ranges(pricing_config)
.is_some_and(|ranges| !ranges.is_empty());
let has_image_output_pricing = has_image_output_matrix
|| has_image_output_ranges
|| explicit_image_output_price_default.is_some();
if tiers.is_empty() && pricing.effective_price_per_request().is_none() {
if tiers.is_empty()
&& pricing.effective_price_per_request().is_none()
&& !has_image_output_pricing
{
return None;
}
@@ -63,6 +77,10 @@ impl DefaultBillingRuleGenerator {
json!(base_cache_read_price),
);
variables.insert("price_per_request".to_string(), json!(base_request_price));
variables.insert(
"image_output_price_per_image".to_string(),
json!(image_output_price_default),
);
let mut dimension_mappings = BTreeMap::new();
for (name, key, default) in [
@@ -86,6 +104,14 @@ impl DefaultBillingRuleGenerator {
),
("cache_read_tokens", "cache_read_tokens", json!(0)),
("request_count", "request_count", json!(1)),
("image_count", "image_count", json!(0)),
("image_count_unmetered", "image_count_unmetered", json!(0)),
("image_price_key", "image_price_key", json!("default")),
(
"image_output_price_per_image",
"image_output_price_per_image",
json!(image_output_price_default),
),
] {
dimension_mappings.insert(
name.to_string(),
@@ -121,6 +147,10 @@ impl DefaultBillingRuleGenerator {
"cache_read_cost",
"cache_read_tokens * cache_read_price_per_1m / 1000000",
),
(
"image_output_cost",
"image_count_unmetered * image_output_price_per_image",
),
("request_cost", "request_count * price_per_request"),
] {
dimension_mappings.insert(
@@ -209,7 +239,7 @@ impl DefaultBillingRuleGenerator {
id: "__default__".to_string(),
name: format!("Default rule for {}", pricing.global_model_name),
task_type: normalize_task_type(task_type).to_string(),
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + request_cost".to_string(),
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + image_output_cost + request_cost".to_string(),
variables,
dimension_mappings,
scope: "default".to_string(),
@@ -267,3 +297,201 @@ fn build_tier_entries(
})
.collect()
}
pub(crate) fn explicit_image_output_price_entries(
pricing_config: Option<&Value>,
) -> Option<BTreeMap<String, Value>> {
let pricing_config = pricing_config?;
let mut entries = BTreeMap::new();
for key in [
"image_output_prices",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
] {
if let Some(value) = pricing_config.get(key) {
collect_image_output_price_entries(value, &mut entries);
}
}
Some(entries)
}
pub(crate) fn explicit_image_output_price_ranges(
pricing_config: Option<&Value>,
) -> Option<Vec<Value>> {
let pricing_config = pricing_config?;
let Some(value) = pricing_config.get("image_output_price_ranges") else {
return Some(Vec::new());
};
let mut ranges = Vec::new();
match value {
Value::Array(items) => {
for item in items {
let Some(object) = item.as_object() else {
continue;
};
let mut range = serde_json::Map::new();
if let Some(up_to_pixels) = object
.get("up_to_pixels")
.or_else(|| object.get("up_to"))
.or_else(|| object.get("max_pixels"))
{
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
}
if let Some(label) = object.get("label").cloned() {
range.insert("label".to_string(), label);
}
if let Some(prices) = object.get("prices") {
range.insert("prices".to_string(), prices.clone());
} else {
let mut prices = serde_json::Map::new();
for quality in ["low", "medium", "high"] {
if let Some(price) = object.get(quality).and_then(Value::as_f64) {
prices.insert(quality.to_string(), json!(price));
}
}
if prices.is_empty() {
if let Some(price) = object
.get("price_per_image")
.or_else(|| object.get("price"))
.or_else(|| object.get("value"))
.and_then(Value::as_f64)
{
prices.insert("default".to_string(), json!(price));
}
}
if !prices.is_empty() {
range.insert("prices".to_string(), Value::Object(prices));
}
}
if !range.is_empty() {
ranges.push(Value::Object(range));
}
}
}
Value::Object(object) => {
for (key, item) in object {
let Some(entry) = item.as_object() else {
continue;
};
let mut range = serde_json::Map::new();
if let Some(up_to_pixels) = entry
.get("up_to_pixels")
.or_else(|| entry.get("up_to"))
.or_else(|| entry.get("max_pixels"))
{
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
} else if let Ok(parsed) = key.parse::<u64>() {
range.insert("up_to_pixels".to_string(), json!(parsed));
}
if let Some(label) = entry.get("label").cloned() {
range.insert("label".to_string(), label);
}
if let Some(prices) = entry.get("prices") {
range.insert("prices".to_string(), prices.clone());
}
if !range.is_empty() {
ranges.push(Value::Object(range));
}
}
}
_ => {}
}
Some(ranges)
}
pub(crate) fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
let pricing_config = pricing_config?;
pricing_config
.get("image_output_price_default")
.or_else(|| pricing_config.get("image_price_default"))
.or_else(|| {
pricing_config
.get("image_output_prices")
.and_then(|value| value.get("default"))
})
.and_then(Value::as_f64)
}
fn collect_image_output_price_entries(value: &Value, entries: &mut BTreeMap<String, Value>) {
if let Some(object) = value.as_object() {
for (key, value) in object {
if key.eq_ignore_ascii_case("default") {
continue;
}
if let Some(price) = value.as_f64() {
entries.insert(normalize_image_price_key(key), json!(price));
continue;
}
let Some(nested) = value.as_object() else {
continue;
};
let key_is_quality = matches_quality_key(key);
for (nested_key, nested_value) in nested {
let Some(price) = nested_value.as_f64() else {
continue;
};
let (size, quality) = if key_is_quality {
(nested_key.as_str(), key.as_str())
} else {
(key.as_str(), nested_key.as_str())
};
entries.insert(image_price_key(size, quality), json!(price));
}
}
return;
}
if let Some(items) = value.as_array() {
for item in items.iter().filter_map(Value::as_object) {
let Some(size) = item.get("size").and_then(Value::as_str) else {
continue;
};
let quality = item
.get("quality")
.and_then(Value::as_str)
.unwrap_or("medium");
let Some(price) = item
.get("price_per_image")
.or_else(|| item.get("price"))
.or_else(|| item.get("cost"))
.and_then(Value::as_f64)
else {
continue;
};
entries.insert(image_price_key(size, quality), json!(price));
}
}
}
fn normalize_image_price_key(value: &str) -> String {
if let Some((size, quality)) = value.split_once(':').or_else(|| value.split_once('|')) {
return image_price_key(size, quality);
}
value.trim().to_ascii_lowercase().replace(' ', "")
}
fn image_price_key(size: &str, quality: &str) -> String {
format!(
"{}:{}",
normalize_image_size(size),
normalize_image_quality(quality)
)
}
fn normalize_image_size(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(' ', "")
}
fn normalize_image_quality(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn matches_quality_key(value: &str) -> bool {
matches!(
normalize_image_quality(value).as_str(),
"low" | "medium" | "high"
)
}

View File

@@ -35,7 +35,10 @@ pub async fn enrich_usage_event_with_billing(
data: &dyn BillingModelContextLookup,
event: &mut UsageEvent,
) -> Result<(), DataLayerError> {
if !matches!(event.event_type, UsageEventType::Completed) {
if !matches!(
event.event_type,
UsageEventType::Completed | UsageEventType::Cancelled
) {
event.data.total_cost_usd = Some(0.0);
event.data.actual_total_cost_usd = Some(0.0);
return Ok(());
@@ -122,24 +125,37 @@ fn calculate_billing_computation(
pricing: &BillingModelPricingSnapshot,
event: &UsageEvent,
) -> Result<BillingComputation, DataLayerError> {
let failed =
event.data.status_code.unwrap_or_default() >= 400 || event.data.error_message.is_some();
let is_image_usage = usage_event_is_image_usage(&event.data);
let image_count = if failed {
0
} else {
usage_event_image_count(&event.data).unwrap_or(0)
};
let request_count = if failed {
0
} else if is_image_usage && image_count > 0 {
image_count
} else {
1
};
let input = BillingUsageInput {
task_type: event
.data
.request_type
.clone()
.unwrap_or_else(|| "chat".to_string()),
task_type: if is_image_usage {
"image".to_string()
} else {
event
.data
.request_type
.clone()
.unwrap_or_else(|| "chat".to_string())
},
api_format: event
.data
.endpoint_api_format
.clone()
.or_else(|| event.data.api_format.clone()),
request_count: if event.data.status_code.unwrap_or_default() >= 400
|| event.data.error_message.is_some()
{
0
} else {
1
},
request_count,
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
output_tokens: event.data.output_tokens.unwrap_or_default() as i64,
cache_creation_tokens: event.data.cache_creation_input_tokens.unwrap_or_default() as i64,
@@ -152,6 +168,10 @@ fn calculate_billing_computation(
.cache_creation_ephemeral_1h_input_tokens
.unwrap_or_default() as i64,
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
image_count,
image_size: usage_event_dimension_string(&event.data, "image_size"),
image_quality: usage_event_dimension_string(&event.data, "image_quality"),
image_output_format: usage_event_dimension_string(&event.data, "image_output_format"),
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
};
@@ -162,6 +182,82 @@ fn calculate_billing_computation(
})
}
fn usage_event_is_image_usage(data: &aether_usage_runtime::UsageEventData) -> bool {
data.request_type
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("image"))
|| api_format_endpoint_kind(data.endpoint_api_format.as_deref()) == Some("image")
|| api_format_endpoint_kind(data.api_format.as_deref()) == Some("image")
|| usage_event_image_count(data).is_some_and(|value| value > 0)
}
fn usage_event_image_count(data: &aether_usage_runtime::UsageEventData) -> Option<i64> {
metadata_dimension_i64(data.request_metadata.as_ref(), "dimensions", "image_count")
.or_else(|| {
metadata_dimension_i64(
data.request_metadata.as_ref(),
"billing_dimensions",
"image_count",
)
})
.filter(|value| *value > 0)
}
fn usage_event_dimension_string(
data: &aether_usage_runtime::UsageEventData,
dimension_key: &str,
) -> Option<String> {
metadata_dimension_string(data.request_metadata.as_ref(), "dimensions", dimension_key).or_else(
|| {
metadata_dimension_string(
data.request_metadata.as_ref(),
"billing_dimensions",
dimension_key,
)
},
)
}
fn metadata_dimension_string(
metadata: Option<&Value>,
bag_key: &str,
dimension_key: &str,
) -> Option<String> {
metadata
.and_then(Value::as_object)
.and_then(|object| object.get(bag_key))
.and_then(Value::as_object)
.and_then(|object| object.get(dimension_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn metadata_dimension_i64(
metadata: Option<&Value>,
bag_key: &str,
dimension_key: &str,
) -> Option<i64> {
metadata
.and_then(Value::as_object)
.and_then(|object| object.get(bag_key))
.and_then(Value::as_object)
.and_then(|object| object.get(dimension_key))
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
})
}
fn api_format_endpoint_kind(api_format: Option<&str>) -> Option<&str> {
api_format
.and_then(|value| value.split_once(':').map(|(_, kind)| kind))
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn apply_billing_computation(
event: &mut UsageEvent,
pricing: &BillingModelPricingSnapshot,
@@ -378,6 +474,307 @@ mod tests {
);
}
#[tokio::test]
async fn image_usage_uses_image_count_for_request_cost() {
let lookup = TestLookup {
name_context: Some(
StoredBillingModelContext::new(
"provider-1".to_string(),
Some("pay_as_you_go".to_string()),
Some("key-1".to_string()),
None,
None,
"global-image-1".to_string(),
"gpt-image-2".to_string(),
None,
Some(0.02),
None,
Some("model-image-1".to_string()),
Some("gpt-image-2".to_string()),
None,
None,
None,
)
.expect("billing context should build"),
),
model_id_context: None,
};
let mut event = UsageEvent::new(
UsageEventType::Completed,
"req-image-billing-1",
UsageEventData {
provider_name: "OpenAI Image".to_string(),
model: "gpt-image-2".to_string(),
provider_id: Some("provider-1".to_string()),
provider_api_key_id: Some("key-1".to_string()),
request_type: Some("chat".to_string()),
api_format: Some("openai:chat".to_string()),
endpoint_api_format: Some("openai:image".to_string()),
request_metadata: Some(json!({
"dimensions": {
"image_count": 3
}
})),
status_code: Some(200),
..UsageEventData::default()
},
);
enrich_usage_event_with_billing(&lookup, &mut event)
.await
.expect("billing should succeed");
assert_eq!(event.data.total_cost_usd, Some(0.06));
assert_eq!(event.data.actual_total_cost_usd, Some(0.06));
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("request_count"))
.and_then(Value::as_i64),
Some(3)
);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("image_count"))
.and_then(Value::as_i64),
Some(3)
);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("effective_task_type"))
.and_then(Value::as_str),
Some("image")
);
}
#[tokio::test]
async fn image_usage_uses_configured_output_price_matrix() {
let lookup = TestLookup {
name_context: Some(
StoredBillingModelContext::new(
"provider-1".to_string(),
Some("pay_as_you_go".to_string()),
Some("key-1".to_string()),
None,
None,
"global-image-1".to_string(),
"gpt-image-2".to_string(),
None,
None,
Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 5.0,
"output_price_per_1m": 30.0,
"cache_read_price_per_1m": 1.25
}],
"image_output_price_default": 0.01,
"image_output_prices": {
"1024x1024": {"low": 0.006, "medium": 0.053, "high": 0.211},
"1536x1024": {"low": 0.005, "medium": 0.041, "high": 0.165},
"1024x1536": {"low": 0.005, "medium": 0.041, "high": 0.165}
}
})),
Some("model-image-1".to_string()),
Some("gpt-image-2".to_string()),
None,
None,
None,
)
.expect("billing context should build"),
),
model_id_context: None,
};
let mut event = UsageEvent::new(
UsageEventType::Completed,
"req-image-billing-matrix-1",
UsageEventData {
provider_name: "OpenAI Image".to_string(),
model: "gpt-image-2".to_string(),
provider_id: Some("provider-1".to_string()),
provider_api_key_id: Some("key-1".to_string()),
request_type: Some("chat".to_string()),
api_format: Some("openai:chat".to_string()),
endpoint_api_format: Some("openai:image".to_string()),
request_metadata: Some(json!({
"dimensions": {
"image_count": 2,
"image_size": "1536x1024",
"image_quality": "medium",
"image_output_format": "png"
}
})),
status_code: Some(200),
..UsageEventData::default()
},
);
enrich_usage_event_with_billing(&lookup, &mut event)
.await
.expect("billing should succeed");
assert_eq!(event.data.total_cost_usd, Some(0.082));
assert_eq!(event.data.actual_total_cost_usd, Some(0.082));
let metadata = event.data.request_metadata.as_ref().expect("metadata");
assert_eq!(
metadata
.get("billing_dimensions")
.and_then(|value| value.get("image_price_key"))
.and_then(Value::as_str),
Some("1536x1024:medium")
);
assert_eq!(
metadata
.get("billing_snapshot")
.and_then(|value| value.get("resolved_variables"))
.and_then(|value| value.get("image_output_price_per_image"))
.and_then(Value::as_f64),
Some(0.041)
);
assert_eq!(
metadata
.get("billing_snapshot")
.and_then(|value| value.get("cost_breakdown"))
.and_then(|value| value.get("image_output_cost"))
.and_then(Value::as_f64),
Some(0.082)
);
}
#[tokio::test]
async fn enriches_cancelled_usage_event_with_billing_snapshot() {
let lookup = TestLookup {
name_context: Some(
StoredBillingModelContext::new(
"provider-1".to_string(),
Some("pay_as_you_go".to_string()),
Some("key-1".to_string()),
Some(json!({"openai:responses": 0.5})),
Some(60),
"global-model-1".to_string(),
"gpt-5".to_string(),
None,
Some(0.02),
Some(json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0,"cache_creation_price_per_1m":3.75,"cache_read_price_per_1m":0.30}]})),
Some("model-1".to_string()),
Some("gpt-5-upstream".to_string()),
None,
None,
None,
)
.expect("billing context should build"),
),
model_id_context: None,
};
let mut event = UsageEvent::new(
UsageEventType::Cancelled,
"req-billing-cancelled-1",
UsageEventData {
provider_name: "OpenAI".to_string(),
model: "gpt-5".to_string(),
provider_id: Some("provider-1".to_string()),
provider_api_key_id: Some("key-1".to_string()),
request_type: Some("chat".to_string()),
api_format: Some("openai:responses".to_string()),
endpoint_api_format: Some("openai:responses".to_string()),
input_tokens: Some(1_000),
output_tokens: Some(500),
cache_read_input_tokens: Some(100),
status_code: Some(499),
..UsageEventData::default()
},
);
enrich_usage_event_with_billing(&lookup, &mut event)
.await
.expect("billing should succeed");
assert!(event.data.total_cost_usd.unwrap_or_default() > 0.0);
assert!(event.data.actual_total_cost_usd.unwrap_or_default() > 0.0);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_snapshot"))
.and_then(|value| value.get("status"))
.and_then(Value::as_str),
Some("complete")
);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("request_count"))
.and_then(Value::as_i64),
Some(0)
);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_i64),
Some(900)
);
assert_eq!(
event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_dimensions"))
.and_then(|value| value.get("cache_read_tokens"))
.and_then(Value::as_i64),
Some(100)
);
}
#[tokio::test]
async fn failed_usage_event_remains_unbilled() {
let lookup = TestLookup {
name_context: None,
model_id_context: None,
};
let mut event = UsageEvent::new(
UsageEventType::Failed,
"req-billing-failed-1",
UsageEventData {
provider_name: "OpenAI".to_string(),
model: "gpt-5".to_string(),
provider_id: Some("provider-1".to_string()),
provider_api_key_id: Some("key-1".to_string()),
request_type: Some("chat".to_string()),
input_tokens: Some(1_000),
output_tokens: Some(500),
status_code: Some(500),
..UsageEventData::default()
},
);
enrich_usage_event_with_billing(&lookup, &mut event)
.await
.expect("billing should succeed");
assert_eq!(event.data.total_cost_usd, Some(0.0));
assert_eq!(event.data.actual_total_cost_usd, Some(0.0));
assert!(event.data.request_metadata.is_none());
}
#[tokio::test]
async fn enriches_by_provider_model_id_before_name_fallback() {
let blank_name_context = StoredBillingModelContext::new(

View File

@@ -24,7 +24,7 @@ impl BillingModelPricingSnapshot {
pub fn effective_tiered_pricing(&self) -> Option<&Value> {
self.model_tiered_pricing
.as_ref()
.filter(|value| has_tiered_pricing_tiers(value))
.filter(|value| has_pricing_data(value))
.or(self.default_tiered_pricing.as_ref())
}
@@ -37,7 +37,7 @@ impl BillingModelPricingSnapshot {
if self
.model_tiered_pricing
.as_ref()
.is_some_and(has_tiered_pricing_tiers)
.is_some_and(has_pricing_data)
|| self.model_price_per_request.is_some()
{
"provider_override"
@@ -75,11 +75,29 @@ impl BillingModelPricingSnapshot {
}
}
fn has_tiered_pricing_tiers(value: &Value) -> bool {
fn has_pricing_data(value: &Value) -> bool {
value
.get("tiers")
.and_then(Value::as_array)
.is_some_and(|tiers| !tiers.is_empty())
|| value
.get("image_output_price_default")
.and_then(Value::as_f64)
.is_some()
|| [
"image_output_prices",
"image_output_price_ranges",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
]
.iter()
.any(|key| value.get(key).is_some_and(value_has_entries))
}
fn value_has_entries(value: &Value) -> bool {
value.as_object().is_some_and(|object| !object.is_empty())
|| value.as_array().is_some_and(|items| !items.is_empty())
}
#[cfg(test)]
@@ -147,6 +165,10 @@ pub struct BillingUsageInput {
pub cache_creation_ephemeral_5m_tokens: i64,
pub cache_creation_ephemeral_1h_tokens: i64,
pub cache_read_tokens: i64,
pub image_count: i64,
pub image_size: Option<String>,
pub image_quality: Option<String>,
pub image_output_format: Option<String>,
pub cache_ttl_minutes: Option<i64>,
}
@@ -162,6 +184,10 @@ impl BillingUsageInput {
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: None,
}
}

View File

@@ -3,15 +3,18 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use crate::default_rule::{normalize_task_type, DefaultBillingRuleGenerator};
use crate::default_rule::{
explicit_image_output_price_default, explicit_image_output_price_entries,
explicit_image_output_price_ranges, normalize_task_type, DefaultBillingRuleGenerator,
};
use crate::precision::quantize_cost;
use crate::pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
use crate::schema::{
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
};
use crate::{
normalize_input_tokens_for_billing, ExpressionEvaluationError, FormulaEngine,
FormulaEvaluationStatus,
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
ExpressionEvaluationError, FormulaEngine, FormulaEvaluationStatus,
};
pub struct BillingService {
@@ -43,7 +46,7 @@ impl BillingService {
rule_name: None,
scope: None,
expression: None,
resolved_dimensions: build_dimensions(input),
resolved_dimensions: build_dimensions(input, pricing),
resolved_variables: BTreeMap::new(),
cost_breakdown: BTreeMap::new(),
total_cost: 0.0,
@@ -62,7 +65,7 @@ impl BillingService {
});
};
let dims = build_dimensions(input);
let dims = build_dimensions(input, pricing);
let result = self.engine.evaluate(
&rule.expression,
Some(&rule.variables),
@@ -123,7 +126,10 @@ impl Default for BillingService {
}
}
fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
fn build_dimensions(
input: &BillingUsageInput,
pricing: &BillingModelPricingSnapshot,
) -> BTreeMap<String, Value> {
let normalized_input_tokens = normalize_input_tokens_for_billing(
input.api_format.as_deref(),
input.input_tokens,
@@ -136,10 +142,14 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
.cache_creation_tokens
.saturating_sub(classified_cache_creation_tokens)
.max(0);
let total_input_context = input
.input_tokens
.saturating_add(input.cache_creation_tokens)
.saturating_add(input.cache_read_tokens);
let total_input_context = normalize_total_input_context_for_cache_hit_rate(
input.api_format.as_deref(),
input.input_tokens,
input.cache_creation_tokens,
input.cache_read_tokens,
);
let image_output_pricing = image_output_pricing_state(pricing);
let image_output_resolution = resolve_image_output_price_resolution(pricing, input);
let mut out = BTreeMap::from([
("input_tokens".to_string(), json!(normalized_input_tokens)),
@@ -168,6 +178,35 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
"request_count".to_string(),
json!(input.request_count.max(0)),
),
("image_count".to_string(), json!(input.image_count.max(0))),
(
"image_count_unmetered".to_string(),
json!(if image_output_pricing.enabled {
input.image_count.max(0)
} else {
0
}),
),
(
"image_output_pricing_enabled".to_string(),
json!(image_output_pricing.enabled),
),
(
"image_output_matrix_enabled".to_string(),
json!(image_output_pricing.matrix_enabled),
),
(
"image_output_range_enabled".to_string(),
json!(image_output_pricing.range_enabled),
),
(
"image_output_pricing_mode".to_string(),
json!(image_output_resolution.pricing_mode),
),
(
"image_output_price_per_image".to_string(),
json!(image_output_resolution.price_per_image),
),
(
"total_input_context".to_string(),
json!(total_input_context),
@@ -193,9 +232,346 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
json!(cache_ttl_minutes.max(0)),
);
}
if let Some(image_pixels) = image_output_resolution.image_pixels {
out.insert("image_pixels".to_string(), json!(image_pixels));
}
if let Some(price_bucket) = image_output_resolution.price_bucket.as_ref() {
out.insert("image_output_price_bucket".to_string(), json!(price_bucket));
}
if input.image_count > 0 {
let image_size = input
.image_size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let image_quality = input
.image_quality
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(image_size) = image_size.as_ref() {
out.insert("image_size".to_string(), json!(image_size));
}
if let Some(image_quality) = image_quality.as_ref() {
out.insert("image_quality".to_string(), json!(image_quality));
}
if let (Some(image_size), Some(image_quality)) =
(image_size.as_ref(), image_quality.as_ref())
{
out.insert(
"image_price_key".to_string(),
json!(format!(
"{}:{}",
normalize_image_output_size(image_size),
normalize_image_output_quality(image_quality)
)),
);
}
}
if let Some(output_format) = input
.image_output_format
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
out.insert("image_output_format".to_string(), json!(output_format));
}
out
}
#[derive(Debug, Clone, Copy)]
struct ImageOutputPricingState {
enabled: bool,
matrix_enabled: bool,
range_enabled: bool,
}
#[derive(Debug, Clone)]
struct ImageOutputPriceResolution {
price_per_image: f64,
pricing_mode: &'static str,
price_bucket: Option<String>,
image_pixels: Option<i64>,
}
#[derive(Debug, Clone)]
struct ParsedImageOutputPriceRange {
up_to_pixels: Option<i64>,
label: Option<String>,
prices: BTreeMap<String, f64>,
}
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
let matrix_enabled = pricing_has_image_output_matrix(pricing);
let range_enabled = pricing_has_image_output_ranges(pricing);
let default_enabled = pricing_has_image_output_default_price(pricing);
ImageOutputPricingState {
enabled: matrix_enabled || range_enabled || default_enabled,
matrix_enabled,
range_enabled,
}
}
fn resolve_image_output_price_resolution(
pricing: &BillingModelPricingSnapshot,
input: &BillingUsageInput,
) -> ImageOutputPriceResolution {
let pricing_config = pricing.effective_tiered_pricing();
let default_price = explicit_image_output_price_default(pricing_config);
let image_size = input
.image_size
.as_deref()
.map(normalize_image_output_size)
.filter(|value| !value.is_empty());
let image_quality = input
.image_quality
.as_deref()
.map(normalize_image_output_quality)
.filter(|value| !value.is_empty());
let image_pixels = image_size.as_deref().and_then(parse_image_size_pixels);
if let (Some(size), Some(entries)) = (
image_size.as_deref(),
explicit_image_output_price_entries(pricing_config),
) {
for key in image_price_lookup_keys(size, image_quality.as_deref()) {
if let Some(price) = entries.get(&key).and_then(Value::as_f64) {
return ImageOutputPriceResolution {
price_per_image: price,
pricing_mode: "matrix",
price_bucket: None,
image_pixels,
};
}
}
}
if let Some(pixels) = image_pixels {
if let Some((price, bucket)) = resolve_image_output_range_price(
explicit_image_output_price_ranges(pricing_config).unwrap_or_default(),
pixels,
image_quality.as_deref(),
default_price,
) {
return ImageOutputPriceResolution {
price_per_image: price,
pricing_mode: "pixel_tiers",
price_bucket: Some(bucket),
image_pixels,
};
}
}
if let Some(price) = default_price {
return ImageOutputPriceResolution {
price_per_image: price,
pricing_mode: "per_image",
price_bucket: Some("default".to_string()),
image_pixels,
};
}
ImageOutputPriceResolution {
price_per_image: 0.0,
pricing_mode: "none",
price_bucket: None,
image_pixels,
}
}
fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
return false;
};
[
"image_output_prices",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
]
.iter()
.any(|key| {
config
.get(key)
.is_some_and(image_price_entries_have_matrix_values)
})
}
fn pricing_has_image_output_ranges(pricing: &BillingModelPricingSnapshot) -> bool {
explicit_image_output_price_ranges(pricing.effective_tiered_pricing())
.is_some_and(|ranges| !ranges.is_empty())
}
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
return false;
};
config
.get("image_output_price_default")
.or_else(|| config.get("image_price_default"))
.or_else(|| {
config
.get("image_output_prices")
.and_then(|value| value.get("default"))
})
.and_then(Value::as_f64)
.is_some()
}
fn image_price_entries_have_matrix_values(value: &Value) -> bool {
match value {
Value::Object(object) => object.iter().any(|(key, value)| {
!key.eq_ignore_ascii_case("default")
&& (value.as_f64().is_some() || image_price_entries_have_matrix_values(value))
}),
Value::Array(items) => items.iter().any(image_price_entries_have_matrix_values),
_ => false,
}
}
fn resolve_image_output_range_price(
ranges: Vec<Value>,
image_pixels: i64,
image_quality: Option<&str>,
default_price: Option<f64>,
) -> Option<(f64, String)> {
let mut parsed_ranges = ranges
.iter()
.filter_map(parse_image_output_price_range)
.collect::<Vec<_>>();
parsed_ranges.sort_by(
|left, right| match (left.up_to_pixels, right.up_to_pixels) {
(Some(left), Some(right)) => left.cmp(&right),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
},
);
for range in parsed_ranges {
if !range
.up_to_pixels
.map(|up_to| image_pixels <= up_to)
.unwrap_or(true)
{
continue;
}
let Some(price) =
image_output_price_for_quality(&range.prices, image_quality).or(default_price)
else {
continue;
};
return Some((price, image_output_range_bucket(&range)));
}
None
}
fn parse_image_output_price_range(value: &Value) -> Option<ParsedImageOutputPriceRange> {
let object = value.as_object()?;
let prices = object
.get("prices")
.and_then(Value::as_object)?
.iter()
.filter_map(|(key, value)| {
value
.as_f64()
.map(|price| (key.to_ascii_lowercase(), price))
})
.collect::<BTreeMap<_, _>>();
if prices.is_empty() {
return None;
}
Some(ParsedImageOutputPriceRange {
up_to_pixels: object.get("up_to_pixels").and_then(value_as_positive_i64),
label: object
.get("label")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
prices,
})
}
fn image_output_price_for_quality(
prices: &BTreeMap<String, f64>,
image_quality: Option<&str>,
) -> Option<f64> {
for key in image_quality_lookup_keys(image_quality) {
if let Some(price) = prices.get(&key) {
return Some(*price);
}
}
None
}
fn image_price_lookup_keys(size: &str, image_quality: Option<&str>) -> Vec<String> {
image_quality_lookup_keys(image_quality)
.into_iter()
.filter(|quality| quality != "default")
.map(|quality| format!("{}:{}", size, quality))
.collect()
}
fn image_quality_lookup_keys(image_quality: Option<&str>) -> Vec<String> {
let quality = image_quality
.map(normalize_image_output_quality)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "medium".to_string());
let mut keys = vec![quality.clone()];
if quality == "auto" {
keys.push("medium".to_string());
}
keys.push("default".to_string());
keys
}
fn image_output_range_bucket(range: &ParsedImageOutputPriceRange) -> String {
range
.label
.clone()
.unwrap_or_else(|| match range.up_to_pixels {
Some(up_to_pixels) => format!("<={up_to_pixels}px"),
None => "unbounded".to_string(),
})
}
fn normalize_image_output_size(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace('×', "x")
.chars()
.filter(|ch| !ch.is_whitespace())
.collect()
}
fn normalize_image_output_quality(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn parse_image_size_pixels(size: &str) -> Option<i64> {
let (width, height) = size.split_once('x')?;
let width = width.parse::<i64>().ok()?;
let height = height.parse::<i64>().ok()?;
if width <= 0 || height <= 0 {
return None;
}
width.checked_mul(height)
}
fn value_as_positive_i64(value: &Value) -> Option<i64> {
let parsed = value
.as_i64()
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
.or_else(|| value.as_f64().map(|value| value as i64))
.or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))?;
(parsed > 0).then_some(parsed)
}
fn now_marker() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -254,6 +630,10 @@ mod tests {
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)
@@ -265,6 +645,414 @@ mod tests {
assert_eq!(result.rate_multiplier, 0.5);
}
#[test]
fn openai_cache_hit_context_does_not_double_count_cache_read() {
let result = BillingService::new()
.calculate(
&pricing(),
&BillingUsageInput {
task_type: "chat".to_string(),
api_format: Some("openai:responses".to_string()),
request_count: 1,
input_tokens: 1_000,
output_tokens: 10,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 800,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("input_tokens"),
Some(&json!(200))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("total_input_context"),
Some(&json!(1_000))
);
}
#[test]
fn image_token_usage_without_image_output_price_bills_tokens_only() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 2.0
}]
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 1_000,
output_tokens: 20_000,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 1,
image_size: Some("1024x1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("none"))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_count_unmetered"),
Some(&json!(0))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.0)
);
assert_eq!(result.cost_result.cost, 0.041);
}
#[test]
fn image_default_output_price_adds_image_cost_even_with_token_usage() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 2.0
}],
"image_output_price_default": 0.05
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 1_000,
output_tokens: 20_000,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 1,
image_size: Some("1024x1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("per_image"))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.05)
);
assert_eq!(result.cost_result.cost, 0.091);
}
#[test]
fn image_default_output_price_generates_rule_without_token_tiers() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"image_output_price_default": 0.05
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 2,
image_size: Some("1024x1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.1)
);
assert_eq!(result.cost_result.cost, 0.1);
}
#[test]
fn image_pixel_ranges_generate_rule_without_token_tiers() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"image_output_price_ranges": [{
"up_to_pixels": null,
"prices": { "medium": 0.04 }
}]
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 2,
image_size: Some("1024x1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("pixel_tiers"))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.08)
);
assert_eq!(result.cost_result.cost, 0.08);
}
#[test]
fn image_token_usage_with_matrix_adds_matrix_image_cost() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 2.0
}],
"image_output_price_default": 0.01,
"image_output_prices": {
"1024x1024": { "medium": 0.05 }
}
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 1_000,
output_tokens: 20_000,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 1,
image_size: Some("1024x1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("matrix"))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.05)
);
}
#[test]
fn image_token_usage_with_pixel_ranges_adds_range_image_cost() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 2.0
}],
"image_output_price_default": 0.01,
"image_output_price_ranges": [
{
"up_to_pixels": 1_048_576,
"prices": { "medium": 0.04 }
},
{
"up_to_pixels": 2_097_152,
"prices": { "medium": 0.08 }
}
]
})),
..pricing()
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "image".to_string(),
api_format: Some("openai:image".to_string()),
request_count: 1,
input_tokens: 1_000,
output_tokens: 20_000,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 1,
image_size: Some("1536 x 1024".to_string()),
image_quality: Some("medium".to_string()),
image_output_format: Some("png".to_string()),
cache_ttl_minutes: None,
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("pixel_tiers"))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_pixels"),
Some(&json!(1_572_864))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_output_price_bucket"),
Some(&json!("<=2097152px"))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("image_output_price_per_image"),
Some(&json!(0.08))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.08)
);
assert_eq!(result.cost_result.cost, 0.121);
}
#[test]
fn five_minute_cache_ttl_uses_base_cache_prices() {
let pricing = BillingModelPricingSnapshot {
@@ -311,6 +1099,10 @@ mod tests {
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(5),
},
)
@@ -380,6 +1172,10 @@ mod tests {
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)

View File

@@ -4,8 +4,8 @@ use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use super::{
plan_finite_wallet_debit, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
plan_finite_wallet_debit, settlement_billing_status_for_usage_status,
SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
use crate::DataLayerError;
@@ -102,11 +102,8 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
})));
}
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void".to_string()
};
let mut final_billing_status =
settlement_billing_status_for_usage_status(&input.status).to_string();
let mut settlement = self.wallets.with_mut(|wallets| {
let wallet_id = input
.api_key_id
@@ -306,6 +303,32 @@ mod tests {
assert_eq!(settlement.wallet_balance_after, Some(9.0));
}
#[tokio::test]
async fn settles_cancelled_usage_against_wallet_and_provider_quota() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
let settlement = repository
.settle_usage(UsageSettlementInput {
request_id: "req-cancelled".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
api_key_is_standalone: false,
provider_id: Some("provider-1".to_string()),
status: "cancelled".to_string(),
billing_status: "pending".to_string(),
total_cost_usd: 3.0,
actual_total_cost_usd: 1.5,
finalized_at_unix_secs: Some(200),
})
.await
.expect("settlement should succeed")
.expect("settlement should exist");
assert_eq!(settlement.billing_status, "settled");
assert_eq!(settlement.wallet_balance_before, Some(12.0));
assert_eq!(settlement.wallet_balance_after, Some(9.0));
assert_eq!(settlement.provider_monthly_used_usd, Some(1.5));
}
#[tokio::test]
async fn standalone_key_settlement_never_falls_back_to_owner_wallet() {
let repository = InMemorySettlementRepository::seed(vec![sample_user_wallet(

View File

@@ -36,6 +36,31 @@ fn plan_finite_wallet_debit(
}
}
fn settlement_billing_status_for_usage_status(status: &str) -> &'static str {
match status {
"completed" | "cancelled" => "settled",
_ => "void",
}
}
#[cfg(test)]
mod tests {
use super::settlement_billing_status_for_usage_status;
#[test]
fn cancelled_usage_status_is_billable() {
assert_eq!(
settlement_billing_status_for_usage_status("completed"),
"settled"
);
assert_eq!(
settlement_billing_status_for_usage_status("cancelled"),
"settled"
);
assert_eq!(settlement_billing_status_for_usage_status("failed"), "void");
}
}
#[allow(unused_imports)]
pub(crate) use aether_data_contracts::repository::settlement::{
SettlementRepository, SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput,

View File

@@ -2,8 +2,9 @@ use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, Row};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
finite_wallet_available_usd, plan_finite_wallet_debit,
settlement_billing_status_for_usage_status, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
@@ -364,11 +365,8 @@ impl SettlementWriteRepository for MysqlSettlementRepository {
return Ok(Some(settlement));
}
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void".to_string()
};
let mut final_billing_status =
settlement_billing_status_for_usage_status(&input.status).to_string();
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,

View File

@@ -2,8 +2,9 @@ use async_trait::async_trait;
use sqlx::{PgPool, Row};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
finite_wallet_available_usd, plan_finite_wallet_debit,
settlement_billing_status_for_usage_status, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::postgres::PostgresTransactionRunner;
use crate::error::SqlxResultExt;
@@ -455,11 +456,8 @@ impl SettlementWriteRepository for SqlxSettlementRepository {
return settlement_from_row(&usage_row).map(Some);
}
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void".to_string()
};
let mut final_billing_status =
settlement_billing_status_for_usage_status(&input.status).to_string();
let finalized_at =
i64::try_from(input.finalized_at_unix_secs.unwrap_or_else(|| {
std::time::SystemTime::now()

View File

@@ -2,8 +2,9 @@ use async_trait::async_trait;
use sqlx::{sqlite::SqliteRow, Row};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
finite_wallet_available_usd, plan_finite_wallet_debit,
settlement_billing_status_for_usage_status, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::sqlite::{sqlite_optional_real, sqlite_real, SqlitePool};
use crate::error::SqlResultExt;
@@ -377,11 +378,8 @@ impl SettlementWriteRepository for SqliteSettlementRepository {
return Ok(Some(settlement));
}
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void".to_string()
};
let mut final_billing_status =
settlement_billing_status_for_usage_status(&input.status).to_string();
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,

View File

@@ -136,7 +136,7 @@ fn lifecycle_status_and_billing(event_type: UsageEventType) -> (&'static str, &'
UsageEventType::Streaming => ("streaming", "pending"),
UsageEventType::Completed => ("completed", "pending"),
UsageEventType::Failed => ("failed", "void"),
UsageEventType::Cancelled => ("cancelled", "void"),
UsageEventType::Cancelled => ("cancelled", "pending"),
}
}
@@ -181,6 +181,38 @@ mod tests {
assert_eq!(record.finalized_at_unix_secs, Some(1_700_000_000));
}
#[test]
fn cancelled_terminal_record_stays_pending_for_settlement() {
let record = build_upsert_usage_record_from_event(&UsageEvent {
event_type: UsageEventType::Cancelled,
request_id: "req-cancelled".to_string(),
timestamp_ms: 1_700_000_000_000,
data: UsageEventData {
provider_name: "OpenAI".to_string(),
model: "gpt-5".to_string(),
input_tokens: Some(10),
output_tokens: Some(20),
total_tokens: Some(30),
total_cost_usd: Some(0.03),
actual_total_cost_usd: Some(0.02),
status_code: Some(499),
response_time_ms: Some(200),
first_byte_time_ms: Some(50),
..UsageEventData::default()
},
})
.expect("record should build");
assert_eq!(record.status, "cancelled");
assert_eq!(record.billing_status, "pending");
assert_eq!(record.total_tokens, Some(30));
assert_eq!(record.total_cost_usd, Some(0.03));
assert_eq!(record.actual_total_cost_usd, Some(0.02));
assert_eq!(record.status_code, Some(499));
assert_eq!(record.response_time_ms, Some(200));
assert_eq!(record.first_byte_time_ms, Some(50));
}
#[test]
fn sanitizes_request_metadata_before_building_upsert_record() {
let record = build_upsert_usage_record_from_event(&UsageEvent {

View File

@@ -164,6 +164,29 @@ mod tests {
assert!(!inputs[0].api_key_is_standalone);
}
#[tokio::test]
async fn settles_pending_cancelled_usage() {
let writer = TestSettlementWriter {
has_writer: true,
..Default::default()
};
let mut usage = sample_usage();
usage.status = "cancelled".to_string();
usage.status_code = Some(499);
settle_usage_if_needed(&writer, &usage)
.await
.expect("settlement should succeed");
let inputs = writer.inputs.lock().expect("settlement inputs lock");
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0].request_id, "req-1");
assert_eq!(inputs[0].status, "cancelled");
assert_eq!(inputs[0].billing_status, "pending");
assert_eq!(inputs[0].total_cost_usd, 1.25);
assert_eq!(inputs[0].actual_total_cost_usd, 0.75);
}
#[tokio::test]
async fn propagates_standalone_key_flag_from_usage_metadata() {
let writer = TestSettlementWriter {

View File

@@ -27,15 +27,21 @@ impl UsageMapper {
}
derive_missing_input_tokens(raw_usage, api_format, &mut usage);
copy_explicit_total_tokens(raw_usage, api_format, &mut usage);
usage.normalize_cache_creation_breakdown()
}
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
let family = api_family(api_format);
let Some(usage_value) = resolve_usage_value(response, family.as_str()) else {
return StandardizedUsage::new();
let mut usage = if let Some(usage_value) = resolve_usage_value(response, family.as_str()) {
Self::map(usage_value, api_format, None)
} else {
StandardizedUsage::new()
};
Self::map(usage_value, api_format, None)
if is_openai_image_api(api_format) {
apply_openai_image_response_dimensions(response, &mut usage);
}
usage
}
}
@@ -59,6 +65,53 @@ fn api_family(api_format: &str) -> String {
.to_ascii_lowercase()
}
fn api_kind(api_format: &str) -> String {
api_format
.split(':')
.nth(1)
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
fn is_openai_image_api(api_format: &str) -> bool {
api_family(api_format).as_str() == "openai" && api_kind(api_format).as_str() == "image"
}
fn apply_openai_image_response_dimensions(
response: &serde_json::Value,
usage: &mut StandardizedUsage,
) {
let image_count = openai_image_response_image_count(response);
if image_count <= 0 {
return;
}
usage.request_count = image_count;
usage
.dimensions
.insert("image_count".to_string(), serde_json::json!(image_count));
}
fn openai_image_response_image_count(response: &serde_json::Value) -> i64 {
response
.get("data")
.and_then(serde_json::Value::as_array)
.map(|items| items.len() as i64)
.filter(|value| *value > 0)
.or_else(|| image_result_count(response.get("result")))
.unwrap_or(0)
}
fn image_result_count(value: Option<&serde_json::Value>) -> Option<i64> {
match value? {
serde_json::Value::Array(items) => Some(items.len() as i64).filter(|count| *count > 0),
serde_json::Value::Object(object) if !object.is_empty() => Some(1),
serde_json::Value::String(text) if !text.trim().is_empty() => Some(1),
_ => None,
}
}
fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
let mut mapping = BTreeMap::new();
match api_family(api_format).as_str() {
@@ -189,6 +242,22 @@ fn derive_missing_input_tokens(
}
}
fn copy_explicit_total_tokens(
raw_usage: &serde_json::Value,
api_format: &str,
usage: &mut StandardizedUsage,
) {
let total_tokens = match api_family(api_format).as_str() {
"gemini" => numeric_i64(raw_usage.get("totalTokenCount")),
_ => numeric_i64(raw_usage.get("total_tokens")),
};
if let Some(total_tokens) = total_tokens.filter(|value| *value > 0) {
usage
.dimensions
.insert("total_tokens".to_string(), serde_json::json!(total_tokens));
}
}
fn numeric_i64(value: Option<&serde_json::Value>) -> Option<i64> {
value.and_then(|value| {
value
@@ -603,4 +672,47 @@ mod tests {
assert_eq!(usage.output_tokens, 6);
assert_eq!(usage.cache_read_tokens, 2);
}
#[test]
fn maps_openai_image_response_dimensions_without_usage() {
let usage = map_usage_from_response(
&serde_json::json!({
"created": 1_700_000_000,
"data": [
{ "b64_json": "abc" },
{ "url": "https://example.test/image.png" }
]
}),
"openai:image",
);
assert_eq!(usage.request_count, 2);
assert_eq!(
usage.dimensions.get("image_count"),
Some(&serde_json::json!(2))
);
}
#[test]
fn maps_openai_image_response_dimensions_with_native_usage() {
let usage = map_usage_from_response(
&serde_json::json!({
"usage": {
"input_tokens": 11,
"output_tokens": 22,
"total_tokens": 33
},
"data": [{ "b64_json": "abc" }]
}),
"openai:image",
);
assert_eq!(usage.input_tokens, 11);
assert_eq!(usage.output_tokens, 22);
assert_eq!(usage.request_count, 1);
assert_eq!(
usage.dimensions.get("image_count"),
Some(&serde_json::json!(1))
);
}
}

File diff suppressed because it is too large Load Diff