Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ mod stream;
pub use buffered::OpenAiChatCodec;
pub use stream::OpenAiChatStreamCodec;

pub(crate) use buffered::{decode_file_source, decode_image_source};
pub(crate) use buffered::{decode_file_source, decode_image_source, parse_arguments};
133 changes: 89 additions & 44 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::codecs::common::{
collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id,
is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
};
use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
use crate::codecs::openai_chat::{decode_file_source, decode_image_source, parse_arguments};
use crate::codecs::openai_media::{
ImagePayload, file_payload, file_source_text, image_payload, image_source_text,
};
Expand Down Expand Up @@ -334,6 +334,18 @@ impl FormatCodec for OpenAiResponsesCodec {
}
}
}
if !has_output
&& let Some(text) = body
.get("output_text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
{
has_output = true;
content.push(ContentBlock::Text {
text: text.to_string(),
});
stop_reason = Some(StopReason::EndTurn);
}
// The truncation signal is on the response, not the output items.
if body.get("status").and_then(Value::as_str) == Some("incomplete")
&& body
Expand Down Expand Up @@ -1774,7 +1786,9 @@ fn decode_responses_output_item(
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
arguments: super::call_arguments(item)
.map(parse_arguments)
.unwrap_or_else(|| json!({})),
})],
stop_reason: Some(StopReason::ToolUse),
})),
Expand Down Expand Up @@ -2019,57 +2033,88 @@ fn decode_responses_usage(value: Option<&Value>) -> Usage {
let Some(value) = value.and_then(Value::as_object) else {
return Usage::default();
};
let aggregate_input_tokens = value
.get("input_tokens")
.or_else(|| value.get("prompt_tokens"))
.and_then(Value::as_u64);
let cached_input_tokens = value
.get("input_tokens_details")
.or_else(|| value.get("prompt_tokens_details"))
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_u64);
let cache_creation_input_tokens = value
.get("input_tokens_details")
.and_then(|details| details.get("cache_write_tokens"))
.and_then(Value::as_u64)
.or_else(|| {
value.get("prompt_tokens_details").and_then(|details| {
details
.get("cache_write_tokens")
.and_then(Value::as_u64)
.or_else(|| details.get("cache_creation_tokens").and_then(Value::as_u64))
})
});
let aggregate_input_tokens = super::usage_u64(
value,
&[
"input_tokens",
"inputTokens",
"prompt_tokens",
"promptTokens",
],
);
let cached_input_tokens =
super::usage_u64(value, &["cache_read_input_tokens", "cacheReadInputTokens"]).or_else(
|| {
super::usage_detail_u64(
value,
&[
"input_tokens_details",
"inputTokensDetails",
"prompt_tokens_details",
"promptTokensDetails",
],
&["cached_tokens", "cachedTokens"],
)
},
);
let cache_creation_input_tokens = super::usage_u64(
value,
&[
"cache_creation_input_tokens",
"cacheCreationInputTokens",
"cacheWriteInputTokens",
],
)
.or_else(|| {
super::usage_detail_u64(
value,
&[
"input_tokens_details",
"inputTokensDetails",
"prompt_tokens_details",
"promptTokensDetails",
],
&[
"cache_write_tokens",
"cacheWriteTokens",
"cache_creation_tokens",
"cacheCreationTokens",
],
)
});
let input_tokens = aggregate_input_tokens.map(|tokens| {
tokens
.saturating_sub(cached_input_tokens.unwrap_or(0))
.saturating_sub(cache_creation_input_tokens.unwrap_or(0))
});
let output_tokens = value
.get("output_tokens")
.or_else(|| value.get("completion_tokens"))
.and_then(Value::as_u64);
let output_tokens = super::usage_u64(
value,
&[
"output_tokens",
"outputTokens",
"completion_tokens",
"completionTokens",
],
);
Usage {
input_tokens,
cache: Usage::cache_details(cached_input_tokens, cache_creation_input_tokens),
output_tokens,
total_tokens: value
.get("total_tokens")
.and_then(Value::as_u64)
.or_else(|| {
aggregate_input_tokens
.zip(output_tokens)
.map(|(input, output)| input + output)
}),
reasoning_tokens: value
.get("output_tokens_details")
.and_then(|details| details.get("reasoning_tokens"))
.or_else(|| {
value
.get("completion_tokens_details")
.and_then(|details| details.get("reasoning_tokens"))
})
.and_then(Value::as_u64),
total_tokens: super::usage_u64(value, &["total_tokens", "totalTokens"]).or_else(|| {
aggregate_input_tokens
.zip(output_tokens)
.map(|(input, output)| input + output)
}),
reasoning_tokens: super::usage_detail_u64(
value,
&[
"output_tokens_details",
"outputTokensDetails",
"completion_tokens_details",
"completionTokensDetails",
],
&["reasoning_tokens", "reasoningTokens", "thinkingTokens"],
),
}
}

Expand Down
26 changes: 26 additions & 0 deletions crates/switchyard-translation/src/codecs/responses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,38 @@

//! OpenAI Responses buffered and streaming codecs.

use serde_json::{Map, Value};

mod buffered;
mod stream;

pub use buffered::OpenAiResponsesCodec;
pub use stream::OpenAiResponsesStreamCodec;

pub(super) fn call_arguments(item: &Map<String, Value>) -> Option<&Value> {
["arguments", "input", "payload"]
.into_iter()
.filter_map(|field| item.get(field))
.find(|value| !value.is_null() && !matches!(value, Value::String(text) if text.is_empty()))
}

pub(super) fn usage_u64(usage: &Map<String, Value>, fields: &[&str]) -> Option<u64> {
fields
.iter()
.find_map(|field| usage.get(*field).and_then(Value::as_u64))
}

pub(super) fn usage_detail_u64(
usage: &Map<String, Value>,
detail_fields: &[&str],
value_fields: &[&str],
) -> Option<u64> {
detail_fields.iter().find_map(|detail_field| {
let details = usage.get(*detail_field)?.as_object()?;
usage_u64(details, value_fields)
})
}

pub(crate) fn is_native_output(kind: &str) -> bool {
matches!(
kind,
Expand Down
141 changes: 107 additions & 34 deletions crates/switchyard-translation/src/codecs/responses/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,23 +745,26 @@ fn decode_responses_completed_item(
return Vec::new();
}
state.decoded_tool_call = true;
let custom_arguments = (item_type == Some("custom_tool_call")).then(|| {
json!({
let tool = state.tool_states.entry(index).or_default();
let arguments_delta = if item_type == Some("custom_tool_call") {
let arguments = json!({
crate::codex_custom_tools::INPUT_ARGUMENT:
item.get("input").and_then(Value::as_str).unwrap_or_default()
})
.to_string()
});
let arguments = custom_arguments
.as_deref()
.or_else(|| item.get("arguments").and_then(Value::as_str));
let tool = state.tool_states.entry(index).or_default();
let arguments_delta =
match arguments.map(|arguments| snapshot_suffix(&mut tool.decoded_arguments, arguments)) {
.to_string();
match snapshot_suffix(&mut tool.decoded_arguments, &arguments) {
Ok(delta) => delta,
Err(error) => return vec![error],
}
} else {
match super::call_arguments(item)
.map(|snapshot| completed_arguments_suffix(&mut tool.decoded_arguments, snapshot))
{
Some(Ok(delta)) => delta,
Some(Err(error)) => return vec![error],
None => None,
};
}
};
let id = item
.get("call_id")
.or_else(|| item.get("id"))
Expand All @@ -781,6 +784,26 @@ fn decode_responses_completed_item(
Vec::new()
}

fn completed_arguments_suffix(
decoded: &mut String,
snapshot: &Value,
) -> Result<Option<String>, LlmResponseChunk> {
if !snapshot.is_string()
&& !decoded.is_empty()
&& serde_json::from_str::<Value>(decoded)
.ok()
.as_ref()
.is_some_and(|parsed| parsed == snapshot)
{
return Ok(None);
}

match snapshot {
Value::String(text) => snapshot_suffix(decoded, text),
value => snapshot_suffix(decoded, &value.to_string()),
}
}

// A snapshot may extend streamed content, but cannot retract content already sent.
fn snapshot_suffix(
decoded: &mut String,
Expand Down Expand Up @@ -1061,38 +1084,88 @@ fn encode_responses_tool_delta(

// Normalizes OpenAI Responses token usage fields.
fn responses_usage(usage: &serde_json::Map<String, Value>) -> Usage {
let aggregate_input_tokens = usage.get("input_tokens").and_then(Value::as_u64);
let cached_input_tokens = usage
.get("input_tokens_details")
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_u64);
let cache_creation_input_tokens = usage
.get("input_tokens_details")
.and_then(|details| details.get("cache_write_tokens"))
.and_then(Value::as_u64);
let aggregate_input_tokens = super::usage_u64(
usage,
&[
"input_tokens",
"inputTokens",
"prompt_tokens",
"promptTokens",
],
);
let cached_input_tokens =
super::usage_u64(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]).or_else(
|| {
super::usage_detail_u64(
usage,
&[
"input_tokens_details",
"inputTokensDetails",
"prompt_tokens_details",
"promptTokensDetails",
],
&["cached_tokens", "cachedTokens"],
)
},
);
let cache_creation_input_tokens = super::usage_u64(
usage,
&[
"cache_creation_input_tokens",
"cacheCreationInputTokens",
"cacheWriteInputTokens",
],
)
.or_else(|| {
super::usage_detail_u64(
usage,
&[
"input_tokens_details",
"inputTokensDetails",
"prompt_tokens_details",
"promptTokensDetails",
],
&[
"cache_write_tokens",
"cacheWriteTokens",
"cache_creation_tokens",
"cacheCreationTokens",
],
)
});
let input_tokens = aggregate_input_tokens.map(|tokens| {
tokens
.saturating_sub(cached_input_tokens.unwrap_or(0))
.saturating_sub(cache_creation_input_tokens.unwrap_or(0))
});
let output_tokens = usage.get("output_tokens").and_then(Value::as_u64);
let output_tokens = super::usage_u64(
usage,
&[
"output_tokens",
"outputTokens",
"completion_tokens",
"completionTokens",
],
);
Usage {
input_tokens,
cache: Usage::cache_details(cached_input_tokens, cache_creation_input_tokens),
output_tokens,
total_tokens: usage
.get("total_tokens")
.and_then(Value::as_u64)
.or_else(|| Some(aggregate_input_tokens.unwrap_or(0) + output_tokens.unwrap_or(0))),
reasoning_tokens: usage
.get("output_tokens_details")
.and_then(|details| details.get("reasoning_tokens"))
.or_else(|| {
usage
.get("completion_tokens_details")
.and_then(|details| details.get("reasoning_tokens"))
})
.and_then(Value::as_u64),
total_tokens: super::usage_u64(usage, &["total_tokens", "totalTokens"]).or_else(|| {
aggregate_input_tokens
.zip(output_tokens)
.map(|(input, output)| input + output)
}),
reasoning_tokens: super::usage_detail_u64(
usage,
&[
"output_tokens_details",
"outputTokensDetails",
"completion_tokens_details",
"completionTokensDetails",
],
&["reasoning_tokens", "reasoningTokens", "thinkingTokens"],
),
}
}

Expand Down
Loading
Loading