use std::collections::BTreeMap;
use serde_json::{Value, json};
use wiremux_auth::{ResolvedProfile, Wire};
use crate::ir::IrStreamEvent;
use crate::map::MapError;
use super::usage::{from_anthropic, from_chat};
use super::{MAX_TOOL_CALL_INDEX, RawSse, check_index, decode_stream_events, str_field};
pub fn encode_response(wire: Wire, events: &[IrStreamEvent]) -> Result<Value, MapError> {
encode_response_with_model(wire, events, "")
}
pub fn encode_response_with_model(
wire: Wire,
events: &[IrStreamEvent],
model: &str,
) -> Result<Value, MapError> {
match wire {
Wire::ChatCompletions => Ok(encode_chat_complete(events, model)),
Wire::Messages => Ok(encode_messages_complete(events, model)),
Wire::Gemini => Ok(encode_gemini_complete(events, model)),
Wire::Responses => Ok(encode_responses_complete(events, model)),
Wire::Converse => super::converse::encode_complete(events),
_ => Err(MapError::Invalid(format!(
"unsupported wire `{}`",
wire.as_str()
))),
}
}
fn encode_chat_complete(events: &[IrStreamEvent], model: &str) -> Value {
let mut text = String::new();
let mut refusal = String::new();
let mut reasoning = String::new();
let mut reasoning_signature = None;
let mut finish = None;
let mut usage = None;
let mut annotations = Vec::new();
let mut audio_data = String::new();
let mut audio_transcript = String::new();
let mut audio_id = None;
let mut audio_expires = None;
let mut accepted_prediction_tokens = None;
let mut rejected_prediction_tokens = None;
let mut images = Vec::new();
let mut tool_calls = Vec::new();
let mut logprobs_content = Vec::new();
let mut logprobs_refusal = Vec::new();
let mut created = None;
let mut service_tier = None;
let mut system_fingerprint = None;
let mut chat_completion_id = None;
let mut metadata = None;
let mut moderation = None;
let mut current: Option<(String, String, String, bool)> = None;
for ev in events {
match ev {
IrStreamEvent::TextDelta { text: delta } => text.push_str(delta),
IrStreamEvent::RefusalDelta { text: delta } => refusal.push_str(delta),
IrStreamEvent::ReasoningDelta { text: delta } => reasoning.push_str(delta),
IrStreamEvent::ReasoningSignature { signature } => {
reasoning_signature = Some(signature.clone());
}
IrStreamEvent::AnnotationAdded { annotation } => {
annotations.push(super::chat::annotation_to_chat(annotation));
}
IrStreamEvent::AudioDelta { data } => audio_data.push_str(data),
IrStreamEvent::ImageDelta { media_type, data } => {
if !text.is_empty() {
images.push(json!({ "type": "text", "text": text }));
text.clear();
}
images.push(super::chat::chat_image_url_part(media_type, data));
}
IrStreamEvent::AudioTranscriptDelta { text } => audio_transcript.push_str(text),
IrStreamEvent::Logprobs { content, refusal } => {
extend_logprobs_content(&mut logprobs_content, content);
if let Some(refusal) = refusal {
extend_logprobs_content(&mut logprobs_refusal, refusal);
}
}
IrStreamEvent::Created { unix } => created = Some(*unix),
IrStreamEvent::ServiceTier { tier } => service_tier = Some(tier.clone()),
IrStreamEvent::Protocol { item_type, payload } => {
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
if item_type == "system_fingerprint" {
system_fingerprint = Some(text.to_string());
} else if item_type == "chat_completion_id" {
chat_completion_id = Some(text.to_string());
} else if item_type == "chat_audio_id" {
audio_id = Some(text.to_string());
}
} else if item_type == "chat_audio_expires" && payload.is_number() {
audio_expires = Some(payload.clone());
} else if item_type == "chat_accepted_prediction_tokens" {
accepted_prediction_tokens =
payload.as_u64().and_then(|n| u32::try_from(n).ok());
} else if item_type == "chat_rejected_prediction_tokens" {
rejected_prediction_tokens =
payload.as_u64().and_then(|n| u32::try_from(n).ok());
}
}
IrStreamEvent::Metadata { metadata: meta } => metadata = Some(meta.clone()),
IrStreamEvent::Moderation { input, output } => {
moderation = Some((input.clone(), output.clone()));
}
IrStreamEvent::FinishReason { reason, .. } => {
finish = Some(reason.clone());
}
IrStreamEvent::Usage {
prompt_tokens,
completion_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
inference_geo: _,
} => {
usage = Some((
*prompt_tokens,
*completion_tokens,
*cache_read_tokens,
*cache_write_tokens,
*reasoning_tokens,
*audio_tokens,
*completion_audio_tokens,
));
}
IrStreamEvent::ToolCallStart { id, name, .. } => {
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(chat_tool_call_value(&id, &name, &args, custom));
}
current = Some((id.clone(), name.clone(), String::new(), false));
}
IrStreamEvent::CustomToolCallStart { id, name, .. } => {
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(chat_tool_call_value(&id, &name, &args, custom));
}
current = Some((id.clone(), name.clone(), String::new(), true));
}
IrStreamEvent::ToolCallArgDelta { delta, .. }
| IrStreamEvent::CustomToolCallInputDelta { delta, .. } => {
if let Some((_, _, args, _)) = current.as_mut() {
args.push_str(delta);
}
}
IrStreamEvent::ToolCallEnd => {
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(chat_tool_call_value(&id, &name, &args, custom));
}
}
_ => {}
}
}
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(chat_tool_call_value(&id, &name, &args, custom));
}
let saw_tool = !tool_calls.is_empty();
finish = match finish {
Some(reason) => Some(super::chat::finish_after_tool(&reason, saw_tool)),
None if saw_tool => Some("tool_calls".to_string()),
None => None,
};
let mut message = json!({ "role": "assistant" });
let content = chat_message_content(&text, &refusal, &images, tool_calls.is_empty());
message["content"] = content;
if !tool_calls.is_empty() {
message["tool_calls"] = Value::Array(tool_calls);
}
if !refusal.is_empty() {
message["refusal"] = json!(refusal);
}
if !reasoning.is_empty() {
message["reasoning_content"] = json!(reasoning);
}
if let Some(signature) = reasoning_signature {
message["reasoning_signature"] = json!(signature);
}
if !annotations.is_empty() {
message["annotations"] = Value::Array(annotations);
}
if !audio_data.is_empty()
|| !audio_transcript.is_empty()
|| audio_id.is_some()
|| audio_expires.is_some()
{
let mut audio = serde_json::Map::new();
if let Some(id) = audio_id {
audio.insert("id".into(), json!(id));
}
if let Some(expires) = audio_expires {
audio.insert("expires_at".into(), expires);
}
if !audio_data.is_empty() {
audio.insert("data".into(), json!(audio_data));
}
if !audio_transcript.is_empty() {
audio.insert("transcript".into(), json!(audio_transcript));
}
message["audio"] = Value::Object(audio);
}
let mut choice = json!({
"index": 0,
"message": message,
});
if !logprobs_content.is_empty() || !logprobs_refusal.is_empty() {
let mut logprobs = serde_json::Map::new();
if !logprobs_content.is_empty() {
logprobs.insert("content".into(), Value::Array(logprobs_content));
}
if !logprobs_refusal.is_empty() {
logprobs.insert("refusal".into(), Value::Array(logprobs_refusal));
}
choice["logprobs"] = Value::Object(logprobs);
}
if let Some(reason) = finish {
choice["finish_reason"] = json!(reason);
}
let mut out = json!({
"id": chat_completion_id.as_deref().unwrap_or("chatcmpl-wiremux"),
"object": "chat.completion",
"choices": [choice],
});
if !model.is_empty() {
out["model"] = json!(model);
}
if let Some(unix) = created {
out["created"] = json!(unix);
}
if let Some(tier) = service_tier {
out["service_tier"] = json!(tier);
}
if let Some(fingerprint) = system_fingerprint {
out["system_fingerprint"] = json!(fingerprint);
}
if let Some(meta) = metadata.filter(|m| !m.is_empty()) {
out["metadata"] = json!(meta);
}
if let Some((input, output)) = moderation
&& let Some(value) = chat_moderation_value(&input, &output)
{
out["moderation"] = value;
}
if let Some((
prompt,
completion,
cache_read,
cache_write,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
)) = usage
{
let encoded = super::usage::encode_chat(
prompt,
completion,
cache_read,
cache_write,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
);
if let Some(u) = encoded.get("usage").cloned() {
let mut usage_body = u;
super::usage::insert_chat_prediction_tokens(
&mut usage_body,
accepted_prediction_tokens,
rejected_prediction_tokens,
);
out["usage"] = usage_body;
}
}
out
}
fn chat_message_content(text: &str, refusal: &str, images: &[Value], no_tools: bool) -> Value {
if images.is_empty() {
if text.is_empty() && (!refusal.is_empty() || !no_tools) {
return Value::Null;
}
return json!(text);
}
let mut parts = images.to_vec();
if !text.is_empty() {
parts.push(json!({ "type": "text", "text": text }));
}
Value::Array(parts)
}
fn extend_logprobs_content(dst: &mut Vec<Value>, content: &Value) {
super::chat::push_logprob_items(dst, content);
}
fn chat_tool_call_value(id: &str, name: &str, args: &str, custom: bool) -> Value {
if custom {
json!({
"id": id,
"type": "custom",
"custom": { "name": name, "input": args },
})
} else {
json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": args },
})
}
}
fn encode_messages_complete(events: &[IrStreamEvent], model: &str) -> Value {
let mut text = String::new();
let mut refusal = String::new();
let mut reasoning = String::new();
let mut reasoning_signature = None;
let mut finish = None;
let mut messages_id = None;
let mut web_search_requests = None;
let mut web_fetch_requests = None;
let mut cache_creation = None;
let mut usage = None;
let mut service_tier = None;
let mut diagnostics = None;
let mut container = None;
let mut context_management = None;
let mut stop_sequence = None;
let mut tool_calls = Vec::new();
let mut citations = Vec::new();
let mut images = Vec::new();
let mut current: Option<(String, String, String, bool)> = None;
for ev in events {
match ev {
IrStreamEvent::TextDelta { text: delta }
| IrStreamEvent::AudioTranscriptDelta { text: delta } => text.push_str(delta),
IrStreamEvent::RefusalDelta { text: delta } => refusal.push_str(delta),
IrStreamEvent::AnnotationAdded { annotation } => {
citations.push(super::messages::citation_from_annotation(annotation));
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "messages_citation" && payload.is_object() =>
{
citations.push(payload.clone());
}
IrStreamEvent::ImageDelta { media_type, data } => {
if !text.is_empty() {
images.push(json!({ "type": "text", "text": text }));
text.clear();
}
images.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}));
}
IrStreamEvent::ReasoningDelta { text: delta } => reasoning.push_str(delta),
IrStreamEvent::ReasoningSignature { signature } => {
reasoning_signature = Some(signature.clone());
}
IrStreamEvent::ServiceTier { tier } => service_tier = Some(tier.clone()),
IrStreamEvent::Diagnostics { cache_miss_reason } => {
diagnostics = Some(cache_miss_reason.clone());
}
IrStreamEvent::Container { value } => {
container = Some(value.clone());
}
IrStreamEvent::ContextManagement { value } => {
context_management = Some(value.clone());
}
IrStreamEvent::StopSequence { text } => {
stop_sequence = Some(text.clone());
}
IrStreamEvent::FinishReason { reason, .. } => {
finish = Some(super::messages::encode_stop_reason(reason).to_string());
}
IrStreamEvent::Usage {
prompt_tokens,
completion_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
audio_tokens,
completion_audio_tokens: _,
inference_geo,
} => {
usage = Some((
*prompt_tokens,
*completion_tokens,
*cache_read_tokens,
*cache_write_tokens,
*reasoning_tokens,
*audio_tokens,
inference_geo.clone(),
));
}
IrStreamEvent::ToolCallStart { id, name, .. }
| IrStreamEvent::CustomToolCallStart { id, name, .. } => {
let custom = matches!(ev, IrStreamEvent::CustomToolCallStart { .. });
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(messages_tool_use_value(&id, &name, &args, custom));
}
current = Some((id.clone(), name.clone(), String::new(), custom));
}
IrStreamEvent::ToolCallArgDelta { delta, .. }
| IrStreamEvent::CustomToolCallInputDelta { delta, .. } => {
if let Some((_, _, args, _)) = current.as_mut() {
args.push_str(delta);
}
}
IrStreamEvent::ToolCallEnd => {
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(messages_tool_use_value(&id, &name, &args, custom));
}
}
IrStreamEvent::Protocol { item_type, payload }
if messages_content_block(item_type, payload) =>
{
if !text.is_empty() {
images.push(json!({ "type": "text", "text": text }));
text.clear();
}
images.push(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload } if item_type == "messages_id" => {
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
messages_id = Some(text.to_string());
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "messages_web_search_requests"
|| item_type == "messages_web_fetch_requests" =>
{
let count = payload.as_u64().and_then(|count| u32::try_from(count).ok());
if item_type == "messages_web_search_requests" {
web_search_requests = count;
} else {
web_fetch_requests = count;
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "messages_cache_creation" && payload.is_object() =>
{
cache_creation = Some(payload.clone());
}
_ => {}
}
}
if let Some((id, name, args, custom)) = current.take() {
tool_calls.push(messages_tool_use_value(&id, &name, &args, custom));
}
let mut content = Vec::new();
if !reasoning.is_empty() || reasoning_signature.is_some() {
let mut block = json!({ "type": "thinking", "thinking": reasoning });
if let Some(signature) = reasoning_signature {
block["signature"] = json!(signature);
}
content.push(block);
}
if !text.is_empty() {
images.push(json!({ "type": "text", "text": text }));
}
if !citations.is_empty() {
if let Some(block) = images
.iter_mut()
.find(|block| block.get("type").and_then(Value::as_str) == Some("text"))
{
block["citations"] = json!(citations);
} else {
images.push(json!({ "type": "text", "text": "", "citations": citations }));
}
}
content.extend(images);
content.extend(tool_calls);
let mut out = json!({
"id": messages_id.as_deref().unwrap_or("msg_wiremux"),
"type": "message",
"role": "assistant",
"model": model,
"content": content,
});
if let Some(ref reason) = finish {
out["stop_reason"] = json!(reason);
}
if !refusal.is_empty() {
out["stop_details"] = json!({
"type": "refusal",
"explanation": refusal,
});
if finish.as_deref().is_none_or(|r| r == "content_filter") {
out["stop_reason"] = json!("refusal");
}
}
if let Some((prompt, completion, cache_read, cache_write, reasoning_tokens, _, geo)) = usage {
let encoded = super::usage::encode_anthropic(
prompt,
completion,
cache_read,
cache_write,
reasoning_tokens,
geo.as_deref(),
);
if let Some(u) = encoded.get("usage").cloned() {
let mut usage_body = u;
super::usage::insert_messages_server_tool_counts(
&mut usage_body,
web_search_requests,
web_fetch_requests,
);
super::usage::insert_messages_cache_creation(&mut usage_body, cache_creation.as_ref());
out["usage"] = usage_body;
}
}
if let Some(mapped) = service_tier
.as_deref()
.and_then(super::messages::usage_service_tier_to_messages)
{
if !out.get("usage").is_some_and(Value::is_object) {
out["usage"] = json!({ "input_tokens": 0, "output_tokens": 0 });
}
out["usage"]["service_tier"] = json!(mapped);
}
if let Some(reason) = diagnostics {
out["diagnostics"] = json!({ "cache_miss_reason": reason });
}
if let Some(value) = container {
out["container"] = value;
}
if let Some(value) = context_management {
out["context_management"] = value;
}
if let Some(text) = stop_sequence {
out["stop_sequence"] = json!(text);
}
out
}
fn messages_content_block(item_type: &str, payload: &Value) -> bool {
if item_type == "chunk" || item_type == "output_image" {
return false;
}
if super::responses_hosted_item_type(item_type) {
return false;
}
payload.get("type").and_then(Value::as_str) == Some(item_type)
}
fn messages_tool_use_value(id: &str, name: &str, args: &str, custom: bool) -> Value {
let input = if custom {
crate::map::response_custom_tool_input(args)
} else {
serde_json::from_str::<Value>(args).unwrap_or_else(|_| json!({}))
};
json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
})
}
fn encode_gemini_complete(events: &[IrStreamEvent], model: &str) -> Value {
let mut text = String::new();
let mut refusal = String::new();
let mut reasoning = String::new();
let mut reasoning_signature = None;
let mut finish = None;
let mut usage = None;
let mut tool_calls = Vec::new();
let mut grounding_chunks = Vec::new();
let mut grounding_supports = Vec::new();
let mut search_entry = None;
let mut media_parts = Vec::new();
let mut logprobs_content = Vec::new();
let mut service_tier = None;
let mut gemini_response_id = None;
let mut safety_ratings = None;
let mut prompt_safety = None;
let mut gemini_block_reason_message = None;
let mut gemini_traffic_type = None;
let mut gemini_tool_use_prompt_tokens = None;
let mut gemini_avg_logprobs = None;
let mut gemini_candidate_tokens = None;
let mut gemini_prompt_token_details = None;
let mut gemini_candidate_token_details = None;
let mut url_context = None;
let mut citation_metadata = None;
let mut gemini_code_parts = Vec::new();
let mut current: Option<(String, String, String, Option<String>, bool)> = None;
for ev in events {
match ev {
IrStreamEvent::TextDelta { text: delta }
| IrStreamEvent::AudioTranscriptDelta { text: delta } => text.push_str(delta),
IrStreamEvent::RefusalDelta { text: delta } => refusal.push_str(delta),
IrStreamEvent::AnnotationAdded { annotation }
if annotation.get("url_context").and_then(Value::as_bool) != Some(true)
&& annotation.get("citation_metadata").and_then(Value::as_bool)
!= Some(true) =>
{
let idx = grounding_chunks.len();
grounding_chunks.push(super::gemini::grounding_chunk_from_annotation(annotation));
grounding_supports.push(super::gemini::grounding_support_from_annotation(
annotation, idx,
));
}
IrStreamEvent::SearchEntryPoint { rendered_content } => {
search_entry = Some(rendered_content.clone());
}
IrStreamEvent::AudioDelta { data } => {
if !text.is_empty() {
media_parts.push(json!({ "text": text }));
text.clear();
}
media_parts.push(json!({
"inlineData": { "mimeType": "audio/mpeg", "data": data }
}));
}
IrStreamEvent::ImageDelta { media_type, data } => {
if !text.is_empty() {
media_parts.push(json!({ "text": text }));
text.clear();
}
media_parts.push(json!({
"inlineData": { "mimeType": media_type, "data": data }
}));
}
IrStreamEvent::Logprobs { content, refusal } => {
extend_logprobs_content(&mut logprobs_content, content);
if let Some(refusal) = refusal {
extend_logprobs_content(&mut logprobs_content, refusal);
}
}
IrStreamEvent::ReasoningDelta { text: delta } => reasoning.push_str(delta),
IrStreamEvent::ReasoningSignature { signature } => {
reasoning_signature = Some(signature.clone());
}
IrStreamEvent::FinishReason { reason, vendor } => {
finish = Some(super::gemini::finish_token(reason, vendor.as_deref()));
}
IrStreamEvent::Usage {
prompt_tokens,
completion_tokens,
cache_read_tokens,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
..
} => {
usage = Some((
*prompt_tokens,
*completion_tokens,
*cache_read_tokens,
*reasoning_tokens,
*audio_tokens,
*completion_audio_tokens,
));
}
IrStreamEvent::ToolCallStart {
id,
name,
thought_signature,
..
} => {
if let Some((id, name, args, signature, custom)) = current.take()
&& let Some(part) =
gemini_function_call_value(&id, &name, &args, signature.as_deref(), custom)
{
tool_calls.push(part);
}
current = Some((
id.clone(),
name.clone(),
String::new(),
thought_signature
.as_deref()
.filter(|sig| !sig.is_empty())
.map(str::to_string),
false,
));
}
IrStreamEvent::CustomToolCallStart { id, name, .. } => {
if let Some((id, name, args, signature, custom)) = current.take()
&& let Some(part) =
gemini_function_call_value(&id, &name, &args, signature.as_deref(), custom)
{
tool_calls.push(part);
}
current = Some((id.clone(), name.clone(), String::new(), None, true));
}
IrStreamEvent::ToolCallArgDelta { delta, .. }
| IrStreamEvent::CustomToolCallInputDelta { delta, .. } => {
if let Some((_, _, args, _, _)) = current.as_mut() {
args.push_str(delta);
}
}
IrStreamEvent::ToolCallEnd => {
if let Some((id, name, args, signature, custom)) = current.take()
&& let Some(part) =
gemini_function_call_value(&id, &name, &args, signature.as_deref(), custom)
{
tool_calls.push(part);
}
}
IrStreamEvent::ServiceTier { tier } => {
if let Some((mapped, _)) = crate::map::gemini_service_tier(tier) {
service_tier = Some(mapped);
}
}
IrStreamEvent::Protocol { item_type, payload } if item_type == "gemini_response_id" => {
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
gemini_response_id = Some(text.to_string());
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_safety_ratings"
&& payload.as_array().is_some_and(|items| !items.is_empty()) =>
{
safety_ratings = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_prompt_safety"
&& payload.as_array().is_some_and(|items| !items.is_empty()) =>
{
prompt_safety = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_block_reason_message" =>
{
gemini_block_reason_message = payload
.as_str()
.map(str::trim)
.filter(|message| !message.is_empty())
.map(str::to_string);
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_traffic_type" =>
{
gemini_traffic_type = payload
.as_str()
.map(str::trim)
.filter(|kind| !kind.is_empty())
.map(str::to_string);
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_tool_use_prompt_tokens" =>
{
gemini_tool_use_prompt_tokens =
payload.as_u64().and_then(|count| u32::try_from(count).ok());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_avg_logprobs" =>
{
gemini_avg_logprobs = payload.as_f64().filter(|score| score.is_finite());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_candidate_tokens" =>
{
gemini_candidate_tokens =
payload.as_u64().and_then(|count| u32::try_from(count).ok());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_prompt_token_details" && payload.is_array() =>
{
gemini_prompt_token_details = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_candidate_token_details" && payload.is_array() =>
{
gemini_candidate_token_details = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_url_context" && payload.is_object() =>
{
url_context = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_citation_metadata" && payload.is_object() =>
{
citation_metadata = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "gemini_code_part"
&& (payload.get("executableCode").is_some()
|| payload.get("codeExecutionResult").is_some()
|| payload.get("functionResponse").is_some()
|| payload.get("toolCall").is_some()
|| payload.get("toolResponse").is_some()
|| payload.get("fileData").is_some()) =>
{
gemini_code_parts.push(payload.clone());
}
_ => {}
}
}
if let Some((id, name, args, signature, custom)) = current.take()
&& let Some(part) =
gemini_function_call_value(&id, &name, &args, signature.as_deref(), custom)
{
tool_calls.push(part);
}
let mut parts = Vec::new();
if !reasoning.is_empty() || reasoning_signature.is_some() {
let mut part = json!({ "text": reasoning, "thought": true });
if let Some(signature) = reasoning_signature {
part["thoughtSignature"] = json!(signature);
}
parts.push(part);
}
if !text.is_empty() {
media_parts.push(json!({ "text": text }));
}
parts.extend(media_parts);
parts.extend(gemini_code_parts);
parts.extend(tool_calls);
let mut candidate = json!({
"content": {
"role": "model",
"parts": parts,
}
});
if let Some(reason) = finish {
candidate["finishReason"] = json!(reason);
}
if let Some(score) = gemini_avg_logprobs {
candidate["avgLogprobs"] = json!(score);
}
if let Some(count) = gemini_candidate_tokens.filter(|count| *count > 0) {
candidate["tokenCount"] = json!(count);
}
if let Some(ratings) = safety_ratings {
candidate["safetyRatings"] = ratings;
}
if let Some(metadata) = url_context {
candidate["urlContextMetadata"] = metadata;
}
if let Some(metadata) = citation_metadata {
candidate["citationMetadata"] = metadata;
}
if !refusal.is_empty() {
candidate["finishMessage"] = json!(refusal);
}
if !grounding_chunks.is_empty() || search_entry.is_some() {
let mut metadata = serde_json::Map::new();
if !grounding_chunks.is_empty() {
metadata.insert("groundingChunks".into(), json!(grounding_chunks));
metadata.insert("groundingSupports".into(), json!(grounding_supports));
}
if let Some(html) = search_entry {
metadata.insert(
"searchEntryPoint".into(),
json!({ "renderedContent": html }),
);
}
candidate["groundingMetadata"] = Value::Object(metadata);
}
if !logprobs_content.is_empty() {
candidate["logprobsResult"] =
super::gemini::logprobs_to_result(&Value::Array(logprobs_content));
}
let mut out = json!({
"candidates": [candidate],
"responseId": gemini_response_id
.as_deref()
.unwrap_or("gemini-wiremux"),
});
if !model.is_empty() {
out["modelVersion"] = json!(model);
}
if let Some(ratings) = prompt_safety {
out["promptFeedback"] = json!({ "safetyRatings": ratings });
}
if let Some(message) = gemini_block_reason_message {
match out.get_mut("promptFeedback").and_then(Value::as_object_mut) {
Some(feedback) => {
feedback.insert("blockReasonMessage".into(), json!(message));
}
None => {
out["promptFeedback"] = json!({ "blockReasonMessage": message });
}
}
}
if let Some((
prompt,
completion,
cache_read,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
)) = usage
{
let encoded = super::usage::encode_gemini(
prompt,
completion,
cache_read,
reasoning_tokens,
audio_tokens,
completion_audio_tokens,
);
if let Some(meta) = encoded.get("usageMetadata").cloned() {
let mut usage_body = meta;
super::usage::insert_gemini_traffic_type(
&mut usage_body,
gemini_traffic_type.as_deref(),
);
super::usage::insert_gemini_tool_use_prompt_tokens(
&mut usage_body,
gemini_tool_use_prompt_tokens,
);
super::usage::insert_gemini_modality_details(
&mut usage_body,
"promptTokensDetails",
gemini_prompt_token_details.as_ref(),
);
super::usage::insert_gemini_modality_details(
&mut usage_body,
"candidatesTokensDetails",
gemini_candidate_token_details.as_ref(),
);
out["usageMetadata"] = usage_body;
}
}
if let Some(tier) = service_tier {
match out.get_mut("usageMetadata") {
Some(Value::Object(meta)) => {
meta.insert("serviceTier".into(), json!(tier));
}
_ => {
out["usageMetadata"] = json!({ "serviceTier": tier });
}
}
}
out
}
fn gemini_function_call_value(
id: &str,
name: &str,
args: &str,
thought_signature: Option<&str>,
custom: bool,
) -> Option<Value> {
if name.is_empty() {
return None;
}
let parsed = if custom {
crate::map::response_custom_tool_input(args)
} else {
serde_json::from_str::<Value>(args).unwrap_or_else(|_| json!({}))
};
let mut function_call = json!({
"name": name,
"args": parsed,
});
if !id.is_empty() {
function_call["id"] = json!(id);
}
let mut part = json!({ "functionCall": function_call });
if let Some(sig) = thought_signature.filter(|sig| !sig.is_empty()) {
part["thoughtSignature"] = json!(sig);
}
Some(part)
}
fn encode_responses_complete(events: &[IrStreamEvent], model: &str) -> Value {
let mut text = String::new();
let mut refusal = String::new();
let mut reasoning = String::new();
let mut reasoning_signature = None;
let mut finish = None;
let mut responses_id = None;
let mut responses_previous_id = None;
let mut responses_message_id = None;
let mut responses_message_status = None;
let mut responses_message_phase = None;
let mut responses_message_agent = None;
let mut responses_reasoning_id = None;
let mut responses_reasoning_status = None;
let mut responses_reasoning_content = None;
let mut usage = None;
let mut annotations = Vec::new();
let mut audio_data = String::new();
let mut audio_transcript = String::new();
let mut images = Vec::new();
let mut logprobs_content = Vec::new();
let mut created = None;
let mut service_tier = None;
let mut metadata = None;
let mut moderation = None;
let mut tool_calls = Vec::new();
let mut tool_extras: BTreeMap<u32, super::responses::ResponsesToolExtra> = BTreeMap::new();
let mut open_tool: Option<u32> = None;
let mut replay_items = Vec::new();
let mut saw_reasoning_item = false;
let mut current: Option<(String, String, String, bool)> = None;
for ev in events {
match ev {
IrStreamEvent::TextDelta { text: delta } => text.push_str(delta),
IrStreamEvent::RefusalDelta { text: delta } => refusal.push_str(delta),
IrStreamEvent::ReasoningDelta { text: delta } => reasoning.push_str(delta),
IrStreamEvent::ReasoningSignature { signature } => {
reasoning_signature = Some(signature.clone());
}
IrStreamEvent::AnnotationAdded { annotation } => {
annotations.push(super::responses::annotation_without_segment_text(
annotation,
));
}
IrStreamEvent::AudioDelta { data } => audio_data.push_str(data),
IrStreamEvent::ImageDelta { media_type, data } => {
if !text.is_empty() {
images.push(json!({ "type": "output_text", "text": text }));
text.clear();
}
images.push(json!({
"type": "output_image",
"image_url": format!("data:{media_type};base64,{data}")
}));
}
IrStreamEvent::AudioTranscriptDelta { text } => audio_transcript.push_str(text),
IrStreamEvent::Logprobs { content, refusal } => {
extend_logprobs_content(&mut logprobs_content, content);
if let Some(refusal) = refusal {
extend_logprobs_content(&mut logprobs_content, refusal);
}
}
IrStreamEvent::Created { unix } => created = Some(*unix),
IrStreamEvent::ServiceTier { tier } => service_tier = Some(tier.clone()),
IrStreamEvent::Metadata { metadata: meta } => metadata = Some(meta.clone()),
IrStreamEvent::Moderation { input, output } => {
moderation = Some((input.clone(), output.clone()));
}
IrStreamEvent::FinishReason { reason, .. } => {
finish = Some(reason.clone());
}
IrStreamEvent::Usage {
prompt_tokens,
completion_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
audio_tokens,
completion_audio_tokens: _,
inference_geo: _,
} => {
usage = Some((
*prompt_tokens,
*completion_tokens,
*cache_read_tokens,
*cache_write_tokens,
*reasoning_tokens,
*audio_tokens,
));
}
IrStreamEvent::ToolCallStart {
id, name, index, ..
} => {
if let Some((id, name, args, custom)) = current.take() {
let extra = take_tool_extra(&mut open_tool, &mut tool_extras);
tool_calls.push(responses_tool_call_value(&id, &name, &args, custom, &extra));
}
open_tool = Some(*index);
current = Some((id.clone(), name.clone(), String::new(), false));
}
IrStreamEvent::CustomToolCallStart {
id, name, index, ..
} => {
if let Some((id, name, args, custom)) = current.take() {
let extra = take_tool_extra(&mut open_tool, &mut tool_extras);
tool_calls.push(responses_tool_call_value(&id, &name, &args, custom, &extra));
}
open_tool = Some(*index);
current = Some((id.clone(), name.clone(), String::new(), true));
}
IrStreamEvent::ToolCallArgDelta { delta, .. }
| IrStreamEvent::CustomToolCallInputDelta { delta, .. } => {
if let Some((_, _, args, _)) = current.as_mut() {
args.push_str(delta);
}
}
IrStreamEvent::ToolCallEnd => {
if let Some((id, name, args, custom)) = current.take() {
let extra = take_tool_extra(&mut open_tool, &mut tool_extras);
tool_calls.push(responses_tool_call_value(&id, &name, &args, custom, &extra));
}
}
IrStreamEvent::Protocol { item_type, payload }
if let Some((index, extra)) =
super::responses::tool_extra_from_protocol(item_type, payload) =>
{
tool_extras.entry(index).or_default().merge(extra);
}
IrStreamEvent::Protocol { item_type, payload } if item_type == "responses_id" => {
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
responses_id = Some(text.to_string());
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "responses_previous_id" =>
{
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
responses_previous_id = Some(text.to_string());
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "responses_message_id"
|| item_type == "responses_message_status"
|| item_type == "responses_message_phase" =>
{
if let Some(text) = payload.as_str().filter(|text| !text.trim().is_empty()) {
match item_type.as_str() {
"responses_message_id" => responses_message_id = Some(text.to_string()),
"responses_message_status" => {
responses_message_status = Some(text.to_string())
}
_ => responses_message_phase = Some(text.to_string()),
}
}
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "responses_message_agent"
&& payload
.get("agent_name")
.and_then(Value::as_str)
.is_some_and(|name| !name.is_empty()) =>
{
responses_message_agent = Some(payload.clone());
}
IrStreamEvent::Protocol { item_type, payload }
if item_type == "responses_reasoning_id"
|| item_type == "responses_reasoning_status"
|| item_type == "responses_reasoning_content" =>
{
match item_type.as_str() {
"responses_reasoning_id" => {
if let Some(text) = payload.as_str().filter(|text| !text.is_empty()) {
responses_reasoning_id = Some(text.to_string());
}
}
"responses_reasoning_status" => {
if let Some(text) = payload.as_str().filter(|text| !text.is_empty()) {
responses_reasoning_status = Some(text.to_string());
}
}
_ => {
if payload.as_array().is_some_and(|parts| !parts.is_empty()) {
responses_reasoning_content = Some(payload.clone());
}
}
}
}
IrStreamEvent::Protocol { item_type, payload }
if super::responses_output_item(item_type, payload) =>
{
if item_type == "reasoning" {
saw_reasoning_item = true;
}
replay_items.push(payload.clone());
}
_ => {}
}
}
if let Some((id, name, args, custom)) = current.take() {
let extra = take_tool_extra(&mut open_tool, &mut tool_extras);
tool_calls.push(responses_tool_call_value(&id, &name, &args, custom, &extra));
}
let mut output = Vec::new();
output.extend(replay_items);
let keep_reasoning = responses_reasoning_id.is_some()
|| responses_reasoning_status.is_some()
|| responses_reasoning_content.is_some();
if !saw_reasoning_item
&& (!reasoning.is_empty() || reasoning_signature.is_some() || keep_reasoning)
{
let mut item = json!({ "type": "reasoning" });
if let Some(id) = responses_reasoning_id.as_deref() {
item["id"] = json!(id);
}
if let Some(status) = responses_reasoning_status.as_deref() {
item["status"] = json!(status);
}
if !reasoning.is_empty() {
item["summary"] = json!([{ "type": "summary_text", "text": reasoning }]);
}
if let Some(content) = responses_reasoning_content {
item["content"] = content;
}
if let Some(signature) = reasoning_signature {
item["signature"] = json!(signature);
}
output.push(item);
}
if !text.is_empty()
|| !refusal.is_empty()
|| !annotations.is_empty()
|| !logprobs_content.is_empty()
|| !images.is_empty()
{
let mut content = images;
if !text.is_empty() {
content.push(json!({ "type": "output_text", "text": text }));
}
if !annotations.is_empty() || !logprobs_content.is_empty() {
if let Some(block) = content
.iter_mut()
.find(|block| block.get("type").and_then(Value::as_str) == Some("output_text"))
{
if !annotations.is_empty() {
block["annotations"] = json!(annotations);
}
if !logprobs_content.is_empty() {
block["logprobs"] = json!(logprobs_content);
}
} else {
let mut part = json!({ "type": "output_text", "text": "" });
if !annotations.is_empty() {
part["annotations"] = json!(annotations);
}
if !logprobs_content.is_empty() {
part["logprobs"] = json!(logprobs_content);
}
content.push(part);
}
}
if !refusal.is_empty() {
content.push(json!({ "type": "refusal", "refusal": refusal }));
}
let mut message = json!({
"type": "message",
"role": "assistant",
"content": content,
});
if let Some(id) = responses_message_id.as_deref() {
message["id"] = json!(id);
}
if let Some(status) = responses_message_status.as_deref() {
message["status"] = json!(status);
}
if let Some(phase) = responses_message_phase.as_deref() {
message["phase"] = json!(phase);
}
if let Some(agent) = responses_message_agent {
message["agent"] = agent;
}
output.push(message);
}
if !audio_data.is_empty() || !audio_transcript.is_empty() {
let mut item = serde_json::Map::new();
item.insert("type".into(), json!("output_audio"));
if !audio_data.is_empty() {
item.insert("data".into(), json!(audio_data));
}
if !audio_transcript.is_empty() {
item.insert("transcript".into(), json!(audio_transcript));
}
output.push(Value::Object(item));
}
output.extend(tool_calls);
let status = finish
.as_deref()
.map(responses_complete_status)
.unwrap_or("completed");
let mut out = json!({
"id": responses_id.as_deref().unwrap_or("resp_wiremux"),
"object": "response",
"status": status,
"output": output,
});
if let Some(prev) = responses_previous_id.as_deref() {
out["previous_response_id"] = json!(prev);
}
if let Some(detail) = finish
.as_deref()
.and_then(super::responses::incomplete_details_reason)
{
out["incomplete_details"] = json!({ "reason": detail });
}
if !model.is_empty() {
out["model"] = json!(model);
}
if let Some(unix) = created {
out["created_at"] = json!(unix);
}
if let Some(tier) = service_tier {
out["service_tier"] = json!(tier);
}
if let Some(meta) = metadata.filter(|m| !m.is_empty()) {
out["metadata"] = json!(meta);
}
if let Some((input, output)) = moderation
&& let Some(value) = responses_moderation_value(&input, &output)
{
out["moderation"] = value;
}
if !text.is_empty() {
out["output_text"] = json!(text);
}
if let Some((prompt, completion, cache_read, cache_write, reasoning_tokens, _)) = usage {
let encoded = super::usage::encode_responses(
prompt,
completion,
cache_read,
cache_write,
reasoning_tokens,
);
if let Some(u) = encoded.pointer("/response/usage") {
out["usage"] = u.clone();
}
}
out
}
fn responses_complete_status(reason: &str) -> &str {
match reason {
"failed" => "failed",
"incomplete" | "length" | "max_tokens" | "content_filter" => "incomplete",
_ => "completed",
}
}
fn take_tool_extra(
open_tool: &mut Option<u32>,
tool_extras: &mut BTreeMap<u32, super::responses::ResponsesToolExtra>,
) -> super::responses::ResponsesToolExtra {
open_tool
.take()
.and_then(|index| tool_extras.remove(&index))
.unwrap_or_default()
}
fn responses_tool_call_value(
id: &str,
name: &str,
args: &str,
custom: bool,
extra: &super::responses::ResponsesToolExtra,
) -> Value {
let mut item = if custom {
json!({
"type": "custom_tool_call",
"id": id,
"call_id": id,
"name": name,
"input": args,
})
} else {
json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": args,
})
};
extra.write_item(&mut item);
item
}
pub fn decode_response(
wire: Wire,
bytes: &[u8],
profile: &ResolvedProfile,
) -> Result<Vec<IrStreamEvent>, MapError> {
Ok(decode_response_with_loss(wire, bytes, profile)?.0)
}
pub fn decode_response_with_loss(
wire: Wire,
bytes: &[u8],
profile: &ResolvedProfile,
) -> Result<(Vec<IrStreamEvent>, crate::ir::LossReport), MapError> {
let value: Value = serde_json::from_slice(bytes)?;
let events = decode_response_value(wire, &value, profile)?;
let mut report = crate::ir::LossReport::default();
if wire == Wire::Gemini {
record_preserved_gemini_finish(&value, &events, &mut report);
}
Ok((events, report))
}
fn record_preserved_gemini_finish(
value: &Value,
events: &[IrStreamEvent],
report: &mut crate::ir::LossReport,
) {
let Some(vendor) = value
.pointer("/candidates/0/finishReason")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
else {
return;
};
if vendor.eq_ignore_ascii_case("stop") {
return;
}
let preserved = events
.iter()
.any(|ev| matches!(ev, IrStreamEvent::FinishReason { reason, .. } if reason == vendor));
if preserved {
report.record(
"candidates[0].finishReason",
crate::ir::LossAction::Preserve,
vendor,
);
}
}
fn decode_response_value(
wire: Wire,
value: &Value,
profile: &ResolvedProfile,
) -> Result<Vec<IrStreamEvent>, MapError> {
match wire {
Wire::ChatCompletions => decode_chat_complete(value),
Wire::Messages => decode_messages_complete(value),
Wire::Responses => decode_responses_complete(value, profile),
Wire::Gemini => decode_gemini_complete(value, profile),
Wire::Converse => super::converse::decode_complete(value),
_ => Err(MapError::Invalid(format!(
"unsupported wire `{}`",
wire.as_str()
))),
}
}
fn decode_chat_complete(value: &Value) -> Result<Vec<IrStreamEvent>, MapError> {
if !crate::completion::json_has_completion(value)
&& let Some(detail) = plain_error_detail(value)
{
return Err(MapError::Invalid(detail));
}
let mut out = Vec::new();
if let Some(tier) = value
.get("service_tier")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::ServiceTier {
tier: tier.to_string(),
});
}
if let Some(ev) = super::chat::system_fingerprint_event(value) {
out.push(ev);
}
if let Some(ev) = super::chat::completion_id_event(value) {
out.push(ev);
}
if let Some(unix) = value.get("created").and_then(Value::as_i64) {
out.push(IrStreamEvent::Created { unix });
}
if let Some(metadata) = string_metadata(value.get("metadata")) {
out.push(IrStreamEvent::Metadata { metadata });
}
if let Some(ev) = moderation_event(value) {
out.push(ev);
}
if let Some(choice) = value
.get("choices")
.and_then(Value::as_array)
.and_then(|c| c.first())
{
if let Some(message) = choice.get("message") {
if let Some(text) = message
.get("reasoning_content")
.or_else(|| message.get("reasoning"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::ReasoningDelta {
text: text.to_string(),
});
}
if let Some(signature) = message
.get("reasoning_signature")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::ReasoningSignature {
signature: signature.to_string(),
});
}
if let Some(content) = message.get("content") {
out.extend(super::chat::content_events(content));
}
if let Some(text) = message
.get("refusal")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::RefusalDelta {
text: text.to_string(),
});
}
if let Some(anns) = message.get("annotations").and_then(Value::as_array) {
for ann in anns {
if ann.is_object() {
out.push(IrStreamEvent::AnnotationAdded {
annotation: super::chat::annotation_from_chat(ann),
});
}
}
}
if let Some(audio) = message.get("audio").filter(|v| v.is_object()) {
if let Some(data) = str_field(audio, "data").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::AudioDelta { data });
}
if let Some(text) = str_field(audio, "transcript").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::AudioTranscriptDelta { text });
}
out.extend(super::chat::audio_identity_events(audio));
}
if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
for call in calls {
check_index(call, "index", MAX_TOOL_CALL_INDEX, "tool call")?;
out.extend(complete_chat_tool_call(call)?);
}
} else if let Some(fc) = message.get("function_call").filter(|v| v.is_object()) {
out.extend(complete_chat_tool_call(fc)?);
}
}
if let Some(ev) = super::chat::chat_logprobs_event(choice) {
out.push(ev);
}
if let Some(reason) = choice
.get("finish_reason")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::FinishReason {
reason: super::chat::map_finish(reason).to_string(),
vendor: None,
});
}
}
if let Some(usage) = value.get("usage").filter(|v| v.is_object()) {
out.push(from_chat(usage));
out.extend(super::usage::chat_prediction_token_events(usage));
}
Ok(out)
}
fn complete_chat_tool_call(call: &Value) -> Result<Vec<IrStreamEvent>, MapError> {
if let Some(ty) = call.get("type").and_then(Value::as_str)
&& ty == "custom"
{
let custom = call.get("custom").unwrap_or(call);
let id = str_field(call, "id").unwrap_or_default();
let name = str_field(custom, "name").unwrap_or_default();
let input = super::json_text_field(custom, "input")?;
let index = super::chat::tool_call_index(call);
let mut out = vec![IrStreamEvent::CustomToolCallStart { id, name, index }];
if let Some(delta) = input {
out.push(IrStreamEvent::CustomToolCallInputDelta { delta, index });
}
out.push(IrStreamEvent::ToolCallEnd);
return Ok(out);
}
if let Some(ty) = call.get("type").and_then(Value::as_str)
&& ty != "function"
{
return Ok(vec![IrStreamEvent::Protocol {
item_type: "chunk".into(),
payload: call.clone(),
}]);
}
let func = call.get("function").unwrap_or(call);
let id = str_field(call, "id").unwrap_or_default();
let name = str_field(func, "name").unwrap_or_default();
let args = super::json_text_field(func, "arguments")?;
let index = super::chat::tool_call_index(call);
let mut out = vec![IrStreamEvent::ToolCallStart {
id,
name,
thought_signature: None,
index,
}];
if let Some(delta) = args {
out.push(IrStreamEvent::ToolCallArgDelta { delta, index });
}
out.push(IrStreamEvent::ToolCallEnd);
Ok(out)
}
fn decode_messages_complete(value: &Value) -> Result<Vec<IrStreamEvent>, MapError> {
let mut out = Vec::new();
let mut tool_index = 0u32;
if let Some(content) = value.get("content").and_then(Value::as_array) {
for block in content {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(text) = str_field(block, "text").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::TextDelta { text });
}
if let Some(citations) = block.get("citations").and_then(Value::as_array) {
for citation in citations {
if let Some(annotation) =
super::messages::annotation_from_messages_citation(citation)
{
out.push(IrStreamEvent::AnnotationAdded { annotation });
} else if citation.is_object()
&& citation.get("type").and_then(Value::as_str).is_some()
{
out.push(IrStreamEvent::Protocol {
item_type: "messages_citation".into(),
payload: citation.clone(),
});
}
}
}
}
Some("thinking") => {
if let Some(text) = str_field(block, "thinking").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::ReasoningDelta { text });
}
if let Some(signature) = str_field(block, "signature").filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::ReasoningSignature { signature });
}
}
Some("image") => {
if let Some(ev) = super::messages::image_delta_from_block(block) {
out.push(ev);
} else if crate::completion::messages_block_has_payload(block) {
out.push(IrStreamEvent::Protocol {
item_type: "image".into(),
payload: block.clone(),
});
}
}
Some("tool_use") if crate::completion::messages_tool_block_visible(block) => {
let index = tool_index;
tool_index = tool_index.saturating_add(1);
out.push(IrStreamEvent::ToolCallStart {
id: str_field(block, "id").unwrap_or_default(),
name: str_field(block, "name").unwrap_or_default(),
thought_signature: None,
index,
});
if let Some(input) = block.get("input").filter(|v| !v.is_null()) {
out.push(IrStreamEvent::ToolCallArgDelta {
delta: input.to_string(),
index,
});
}
out.push(IrStreamEvent::ToolCallEnd);
}
Some("tool_use") => {}
Some(ty) if crate::completion::messages_block_has_payload(block) => {
out.push(IrStreamEvent::Protocol {
item_type: ty.to_string(),
payload: block.clone(),
});
}
Some(_) => {}
None => {}
}
}
}
if out.is_empty()
&& let Some(detail) = plain_error_detail(value)
{
return Err(MapError::Invalid(detail));
}
if let Some(text) = value
.pointer("/stop_details/explanation")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::RefusalDelta {
text: text.to_string(),
});
}
if let Some(text) = value
.get("stop_sequence")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::StopSequence {
text: text.to_string(),
});
}
if let Some(reason) = value.get("stop_reason").and_then(Value::as_str) {
out.push(IrStreamEvent::FinishReason {
reason: super::messages::map_stop_reason(reason).to_string(),
vendor: None,
});
}
if let Some(reason) = value
.pointer("/diagnostics/cache_miss_reason")
.filter(|v| v.is_object())
{
out.push(IrStreamEvent::Diagnostics {
cache_miss_reason: reason.clone(),
});
}
if let Some(value) = value.get("container").filter(|v| v.is_object()) {
out.push(IrStreamEvent::Container {
value: value.clone(),
});
}
if let Some(managed) = value.get("context_management").filter(|v| v.is_object()) {
out.push(IrStreamEvent::ContextManagement {
value: managed.clone(),
});
}
if let Some(usage) = value.get("usage").filter(|v| v.is_object()) {
if let Some(tier) = super::messages::service_tier_from_usage(usage) {
out.push(IrStreamEvent::ServiceTier { tier });
}
out.push(from_anthropic(usage));
out.extend(super::usage::messages_web_search_events(usage));
out.extend(super::usage::messages_cache_creation_events(usage));
}
if let Some(id) = value
.get("id")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
{
out.push(IrStreamEvent::Protocol {
item_type: "messages_id".into(),
payload: json!(id),
});
}
Ok(out)
}
pub(super) fn responses_gateway_error(value: &Value) -> Option<String> {
if value.get("status").and_then(Value::as_str).is_some() {
return None;
}
if value.get("object").and_then(Value::as_str) == Some("response") {
return None;
}
let ty = value.get("type").and_then(Value::as_str).unwrap_or("");
if ty.starts_with("response.") {
return None;
}
if ["output", "content", "choices", "candidates"]
.iter()
.any(|key| {
value
.get(*key)
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty())
})
{
return None;
}
plain_error_detail(value)
}
fn plain_error_detail(value: &Value) -> Option<String> {
if let Some(text) = crate::completion::json_string_error(value) {
return Some(text.to_string());
}
value
.get("error")
.filter(|err| err.is_object())
.and_then(crate::completion::error_object_detail)
}
pub(crate) fn responses_has_visible_output(value: &Value) -> bool {
if str_field(value, "text").is_some_and(|text| !text.is_empty()) {
return true;
}
let Some(items) = value
.get("output")
.and_then(Value::as_array)
.or_else(|| value.pointer("/response/output").and_then(Value::as_array))
else {
return false;
};
items.iter().any(responses_item_visible)
}
fn responses_item_visible(item: &Value) -> bool {
match item.get("type").and_then(Value::as_str) {
Some("message") => item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(responses_part_visible)),
Some("function_call") | Some("custom_tool_call") => responses_call_visible(item),
Some("reasoning") => responses_reasoning_visible(item),
Some("output_audio") | Some("audio") => responses_part_visible(item),
Some(_) => responses_hosted_visible(item),
None => false,
}
}
fn responses_reasoning_visible(item: &Value) -> bool {
str_field(item, "encrypted_content").is_some_and(|text| !text.is_empty())
|| item
.get("summary")
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(responses_part_visible))
|| item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(responses_part_visible))
}
fn responses_hosted_visible(item: &Value) -> bool {
let Some(map) = item.as_object() else {
return false;
};
map.iter()
.any(|(key, value)| key != "type" && responses_hosted_field_present(value))
}
fn responses_hosted_field_present(value: &Value) -> bool {
match value {
Value::String(text) => !text.is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
Value::Number(_) | Value::Bool(_) => true,
Value::Null => false,
}
}
fn responses_part_visible(part: &Value) -> bool {
["text", "refusal", "data", "transcript", "image_url"]
.iter()
.any(|key| str_field(part, key).is_some_and(|text| !text.is_empty()))
}
fn responses_call_visible(item: &Value) -> bool {
["name", "call_id", "id"]
.iter()
.any(|key| str_field(item, key).is_some_and(|text| !text.is_empty()))
|| responses_arg_present(item.get("arguments"))
|| responses_arg_present(item.get("input"))
}
fn responses_arg_present(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => false,
Some(Value::String(text)) => !text.is_empty(),
Some(_) => true,
}
}
fn responses_surface_error(value: &Value) -> Option<String> {
if let Some(detail) = plain_error_detail(value) {
return Some(detail);
}
value
.get("last_error")
.filter(|err| err.is_object())
.and_then(crate::completion::error_object_detail)
}
fn decode_responses_complete(
value: &Value,
profile: &ResolvedProfile,
) -> Result<Vec<IrStreamEvent>, MapError> {
if let Some(detail) = responses_gateway_error(value) {
return Err(MapError::Invalid(detail));
}
if let Some(detail) = responses_surface_error(value)
&& !responses_has_visible_output(value)
{
return Err(MapError::Invalid(detail));
}
let name = value.get("type").and_then(Value::as_str).unwrap_or("");
let (event, data) = if name == "response.completed" || name == "response.incomplete" {
(name.to_string(), value.to_string())
} else {
let status = value.get("status").and_then(Value::as_str).unwrap_or("");
let event = match status {
"incomplete" => "response.incomplete",
"failed" => "response.failed",
_ => "response.completed",
};
(
event.to_string(),
json!({ "type": event, "response": value }).to_string(),
)
};
let mut events = decode_stream_events(
Wire::Responses,
&RawSse {
event: Some(event),
data,
},
profile,
)?;
let extra = complete_responses_output_events(value)?;
if extra.is_empty() {
return Ok(events);
}
let insert_at = events
.iter()
.position(|ev| {
matches!(
ev,
IrStreamEvent::FinishReason { .. }
| IrStreamEvent::Usage { .. }
| IrStreamEvent::Done
)
})
.unwrap_or(events.len());
events.splice(insert_at..insert_at, extra);
if let Some(ev) = created_event(value) {
events.push(ev);
}
if let Some(ev) = service_tier_event(value) {
events.push(ev);
}
if let Some(ev) = metadata_event(value) {
events.push(ev);
}
if let Some(ev) = moderation_event(value) {
events.push(ev);
}
Ok(events)
}
fn string_metadata(value: Option<&Value>) -> Option<BTreeMap<String, String>> {
let obj = value.and_then(Value::as_object)?;
let map: BTreeMap<String, String> = obj
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
if map.is_empty() { None } else { Some(map) }
}
pub(super) fn created_event(value: &Value) -> Option<IrStreamEvent> {
value
.get("created_at")
.or_else(|| value.pointer("/response/created_at"))
.and_then(Value::as_i64)
.map(|unix| IrStreamEvent::Created { unix })
}
pub(super) fn metadata_event(value: &Value) -> Option<IrStreamEvent> {
string_metadata(
value
.get("metadata")
.or_else(|| value.pointer("/response/metadata")),
)
.map(|metadata| IrStreamEvent::Metadata { metadata })
}
pub(super) fn service_tier_event(value: &Value) -> Option<IrStreamEvent> {
value
.get("service_tier")
.or_else(|| value.pointer("/response/service_tier"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|tier| IrStreamEvent::ServiceTier {
tier: tier.to_string(),
})
}
pub(super) fn moderation_event(value: &Value) -> Option<IrStreamEvent> {
let moderation = value
.get("moderation")
.or_else(|| value.pointer("/response/moderation"))?;
if !moderation.is_object() {
return None;
}
let input = moderation.get("input").map(to_chat_moderation_side);
let output = moderation.get("output").map(to_chat_moderation_side);
if input.is_none() && output.is_none() {
return None;
}
Some(IrStreamEvent::Moderation { input, output })
}
fn chat_moderation_value(input: &Option<Value>, output: &Option<Value>) -> Option<Value> {
moderation_object(input, output, |side| side.clone())
}
pub(super) fn responses_moderation_value(
input: &Option<Value>,
output: &Option<Value>,
) -> Option<Value> {
moderation_object(input, output, to_responses_moderation_side)
}
fn moderation_object(
input: &Option<Value>,
output: &Option<Value>,
map_side: impl Fn(&Value) -> Value,
) -> Option<Value> {
let mut obj = serde_json::Map::new();
if let Some(input) = input {
obj.insert("input".into(), map_side(input));
}
if let Some(output) = output {
obj.insert("output".into(), map_side(output));
}
if obj.is_empty() {
None
} else {
Some(Value::Object(obj))
}
}
fn to_chat_moderation_side(value: &Value) -> Value {
match value.get("type").and_then(Value::as_str) {
Some("error") | Some("moderation_results") => value.clone(),
Some("moderation_result") => moderation_result_to_chat(value),
_ if value.get("results").is_some() => value.clone(),
_ if value.get("flagged").is_some() || value.get("categories").is_some() => {
moderation_result_to_chat(value)
}
_ => value.clone(),
}
}
fn moderation_result_to_chat(value: &Value) -> Value {
let mut result = serde_json::Map::new();
for key in [
"flagged",
"categories",
"category_scores",
"category_applied_input_types",
] {
if let Some(v) = value.get(key) {
result.insert(key.to_string(), v.clone());
}
}
json!({
"type": "moderation_results",
"model": value.get("model").cloned().unwrap_or(json!("")),
"results": [Value::Object(result)],
})
}
fn to_responses_moderation_side(value: &Value) -> Value {
match value.get("type").and_then(Value::as_str) {
Some("error") | Some("moderation_result") => value.clone(),
Some("moderation_results") => moderation_results_to_responses(value),
_ if value.get("results").and_then(Value::as_array).is_some() => {
moderation_results_to_responses(value)
}
_ => value.clone(),
}
}
fn moderation_results_to_responses(value: &Value) -> Value {
let first = value
.get("results")
.and_then(Value::as_array)
.and_then(|a| a.first());
let model = value
.get("model")
.cloned()
.or_else(|| first.and_then(|r| r.get("model").cloned()))
.unwrap_or(json!(""));
let mut obj = serde_json::Map::new();
obj.insert("type".into(), json!("moderation_result"));
obj.insert("model".into(), model);
if let Some(first) = first {
for key in [
"flagged",
"categories",
"category_scores",
"category_applied_input_types",
] {
if let Some(v) = first.get(key) {
obj.insert(key.to_string(), v.clone());
}
}
}
Value::Object(obj)
}
fn complete_responses_output_events(value: &Value) -> Result<Vec<IrStreamEvent>, MapError> {
let Some(items) = value
.get("output")
.and_then(Value::as_array)
.or_else(|| value.pointer("/response/output").and_then(Value::as_array))
else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let mut tool_index = 0u32;
for item in items {
match item.get("type").and_then(Value::as_str) {
Some("message") => {
out.extend(super::responses::message_item_identity_events(item));
let Some(content) = item.get("content").and_then(Value::as_array) else {
continue;
};
for part in content {
let ty = part.get("type").and_then(Value::as_str);
match ty {
Some("output_text") | Some("text") => {
if let Some(text) = str_field(part, "text").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::TextDelta { text });
}
if let Some(anns) = part.get("annotations").and_then(Value::as_array) {
for ann in anns {
if ann.is_object() {
out.push(IrStreamEvent::AnnotationAdded {
annotation: ann.clone(),
});
}
}
}
if let Some(content) = super::responses::logprobs_array(part) {
out.push(IrStreamEvent::Logprobs {
content,
refusal: None,
});
}
}
Some("refusal") => {
if let Some(text) = str_field(part, "refusal")
.or_else(|| str_field(part, "text"))
.filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::RefusalDelta { text });
}
}
Some("output_audio") | Some("audio") => {
push_output_audio_events(&mut out, part);
}
Some("output_image") => {
if let Some(ev) = super::responses::image_delta_from_output_image(part)
{
out.push(ev);
} else {
out.push(IrStreamEvent::Protocol {
item_type: "output_image".into(),
payload: part.clone(),
});
}
}
_ => {}
}
}
}
Some("output_audio") => {
push_output_audio_events(&mut out, item);
}
Some("function_call") => {
let index = tool_index;
tool_index = tool_index.saturating_add(1);
if let Some(ev) = super::responses::tool_item_id_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_caller_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_created_by_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_namespace_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_status_event(item, index) {
out.push(ev);
}
out.push(IrStreamEvent::ToolCallStart {
id: str_field(item, "call_id")
.or_else(|| str_field(item, "id"))
.unwrap_or_default(),
name: str_field(item, "name").unwrap_or_default(),
thought_signature: None,
index,
});
if let Some(delta) = super::json_text_field(item, "arguments")? {
out.push(IrStreamEvent::ToolCallArgDelta { delta, index });
}
out.push(IrStreamEvent::ToolCallEnd);
}
Some("custom_tool_call") => {
let index = tool_index;
tool_index = tool_index.saturating_add(1);
if let Some(ev) = super::responses::tool_item_id_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_caller_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_created_by_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_namespace_event(item, index) {
out.push(ev);
}
if let Some(ev) = super::responses::tool_item_status_event(item, index) {
out.push(ev);
}
out.push(IrStreamEvent::CustomToolCallStart {
id: str_field(item, "call_id")
.or_else(|| str_field(item, "id"))
.unwrap_or_default(),
name: str_field(item, "name").unwrap_or_default(),
index,
});
if let Some(delta) = super::json_text_field(item, "input")? {
out.push(IrStreamEvent::CustomToolCallInputDelta { delta, index });
}
out.push(IrStreamEvent::ToolCallEnd);
}
Some("reasoning") => {
out.extend(super::responses::reasoning_item_identity_events(item));
if let Some(parts) = item.get("summary").and_then(Value::as_array) {
for part in parts {
let ty = part.get("type").and_then(Value::as_str);
if matches!(ty, Some("summary_text") | Some("text"))
&& let Some(text) = str_field(part, "text").filter(|s| !s.is_empty())
{
out.push(IrStreamEvent::ReasoningDelta { text });
}
}
}
}
_ => {}
}
}
Ok(out)
}
fn push_output_audio_events(out: &mut Vec<IrStreamEvent>, value: &Value) {
if let Some(data) = str_field(value, "data").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::AudioDelta { data });
}
if let Some(text) = str_field(value, "transcript").filter(|s| !s.is_empty()) {
out.push(IrStreamEvent::AudioTranscriptDelta { text });
}
}
fn decode_gemini_complete(
value: &Value,
profile: &ResolvedProfile,
) -> Result<Vec<IrStreamEvent>, MapError> {
if !crate::completion::json_has_completion(value)
&& crate::completion::json_string_error(value).is_some()
&& let Some(detail) = plain_error_detail(value)
{
return Err(MapError::Invalid(detail));
}
decode_stream_events(
Wire::Gemini,
&RawSse {
event: None,
data: value.to_string(),
},
profile,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::IrStreamEvent;
fn responses_profile() -> ResolvedProfile {
crate::parse_profile_str(
r#"
schema_version = 1
id = "responses"
wire = "responses"
"#,
)
.expect("profile")
}
#[test]
fn responses_gateway_error_is_not_a_completed_response() {
let profile = responses_profile();
let err = decode_response(
Wire::Responses,
br#"{"error":{"message":"model catalog unavailable","type":"server_error"}}"#,
&profile,
)
.expect_err("error object");
let text = err.to_string();
assert!(
text.contains("server_error") && text.contains("model catalog unavailable"),
"{text}"
);
let err = decode_response(
Wire::Responses,
br#"{"error":"model catalog unavailable"}"#,
&profile,
)
.expect_err("string error");
assert!(
err.to_string().contains("model catalog unavailable"),
"{err}"
);
let err = decode_response(
Wire::Responses,
br#"{"id":"resp_1","object":"response","status":"failed","error":{"code":"server_error","message":"upstream failed"},"output":[]}"#,
&profile,
)
.expect_err("status failed with a message");
assert!(
matches!(err, MapError::Invalid(ref text) if text.contains("upstream failed")),
"{err}"
);
let done = decode_response(
Wire::Responses,
br#"{"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}]}"#,
&profile,
)
.expect("completed output");
assert!(
done.iter()
.any(|ev| matches!(ev, IrStreamEvent::TextDelta { text } if text == "Hi")),
"{done:?}"
);
let chat = crate::parse_profile_str(
r#"
schema_version = 1
id = "chat"
wire = "chat-completions"
"#,
)
.expect("chat profile");
let err = decode_response(
Wire::ChatCompletions,
br#"{"error":{"message":"model catalog unavailable","type":"server_error"}}"#,
&chat,
)
.expect_err("chat error object");
assert!(
err.to_string().contains("model catalog unavailable"),
"{err}"
);
let kept = decode_response(
Wire::ChatCompletions,
br#"{"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"error":{"message":"please wait"}}"#,
&chat,
)
.expect("choice beside an error");
assert!(
kept.iter()
.any(|ev| matches!(ev, IrStreamEvent::TextDelta { text } if text == "Hi")),
"{kept:?}"
);
let err = decode_response(
Wire::ChatCompletions,
br#"{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"error":{"message":"overloaded"}}"#,
&chat,
)
.expect_err("empty choice hides the error");
assert!(err.to_string().contains("overloaded"), "{err}");
let err = decode_response(Wire::ChatCompletions, br#"{"error":{"code":503}}"#, &chat)
.expect_err("numeric code");
assert!(err.to_string().contains("503"), "{err}");
for body in [
br#"{"choices":[{"message":{"role":"assistant","content":[{"type":"image_url"}]}}],"error":{"message":"backend failed"}}"#.as_slice(),
br#"{"choices":[{"message":{"role":"assistant","content":[{"type":"input_audio"}]}}],"error":{"message":"backend failed"}}"#.as_slice(),
br#"{"choices":[{"delta":{"tool_calls":[{}]}}],"error":{"message":"backend failed"}}"#.as_slice(),
] {
let err = decode_response(Wire::ChatCompletions, body, &chat).expect_err("empty shell");
assert!(
err.to_string().contains("backend failed"),
"{err} body {}",
String::from_utf8_lossy(body)
);
}
let kept_tool = decode_response(
Wire::ChatCompletions,
br#"{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}],"error":{"message":"backend failed"}}"#,
&chat,
)
.expect("named tool call stays");
assert!(
kept_tool.iter().any(|ev| matches!(
ev,
IrStreamEvent::ToolCallStart { id, name, .. } if id == "call_1" && name == "lookup"
)),
"{kept_tool:?}"
);
let messages = crate::parse_profile_str(
r#"
schema_version = 1
id = "messages"
wire = "messages"
"#,
)
.expect("messages profile");
let err = decode_response(
Wire::Messages,
br#"{"type":"error","error":{"type":"api_error","message":"model catalog unavailable"}}"#,
&messages,
)
.expect_err("messages typed error");
assert!(
err.to_string().contains("model catalog unavailable"),
"{err}"
);
let err = decode_response(
Wire::Messages,
br#"{"error":"model catalog unavailable"}"#,
&messages,
)
.expect_err("messages string error");
assert!(
err.to_string().contains("model catalog unavailable"),
"{err}"
);
let err = decode_response(
Wire::Messages,
br#"{"error":{"message":"model not found"}}"#,
&messages,
)
.expect_err("messages object without type");
assert!(err.to_string().contains("model not found"), "{err}");
let err = decode_response(
Wire::Messages,
br#"{"content":[{"type":"text","text":""}],"error":{"type":"api_error","message":"overloaded"}}"#,
&messages,
)
.expect_err("empty text block hides the error");
assert!(err.to_string().contains("overloaded"), "{err}");
let err = decode_response(
Wire::Messages,
br#"{"content":[{}],"error":{"message":"overloaded"}}"#,
&messages,
)
.expect_err("empty block hides the error");
assert!(err.to_string().contains("overloaded"), "{err}");
let kept_messages = decode_response(
Wire::Messages,
br#"{"content":[{"type":"text","text":"Hi"}],"error":{"message":"model not found"}}"#,
&messages,
)
.expect("content beside an untyped error");
assert!(
kept_messages
.iter()
.any(|ev| matches!(ev, IrStreamEvent::TextDelta { text } if text == "Hi")),
"{kept_messages:?}"
);
let gemini = crate::parse_profile_str(
r#"
schema_version = 1
id = "gemini"
wire = "gemini"
"#,
)
.expect("gemini profile");
let err = decode_response(
Wire::Gemini,
br#"{"error":"model catalog unavailable"}"#,
&gemini,
)
.expect_err("gemini string error");
assert!(
err.to_string().contains("model catalog unavailable"),
"{err}"
);
let err = decode_response(
Wire::Gemini,
br#"{"candidates":[{}],"error":{"message":"overloaded"}}"#,
&gemini,
)
.expect_err("empty candidate hides the error");
match &err {
MapError::HardError { path, detail } => {
assert_eq!(path, "error");
assert!(detail.contains("overloaded"), "{detail}");
}
other => panic!("empty candidate must stay HardError, got {other}"),
}
let stopped = RawSse {
event: None,
data: r#"{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"error":{"code":503,"message":"The model is overloaded. Please try again later.","status":"UNAVAILABLE"}}"#.into(),
};
let err = decode_stream_events(Wire::Gemini, &stopped, &gemini)
.expect_err("finishReason must not hide the error");
assert!(err.to_string().contains("overloaded"), "{err}");
let usage = RawSse {
event: None,
data: r#"{"candidates":[{"content":{"parts":[]}}],"usageMetadata":{"promptTokenCount":1},"error":{"message":"overloaded","status":"UNAVAILABLE"}}"#.into(),
};
let err = decode_stream_events(Wire::Gemini, &usage, &gemini)
.expect_err("usage must not hide the error");
assert!(err.to_string().contains("overloaded"), "{err}");
let kept_gemini = decode_response(
Wire::Gemini,
br#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}],"error":{"message":"overloaded"}}"#,
&gemini,
)
.expect("candidate text beside an error");
assert!(
kept_gemini
.iter()
.any(|ev| matches!(ev, IrStreamEvent::TextDelta { text } if text == "Hi")),
"{kept_gemini:?}"
);
let converse = crate::parse_profile_str(
r#"
schema_version = 1
id = "converse"
wire = "converse"
"#,
)
.expect("converse profile");
let err = decode_response(
Wire::Converse,
br#"{"__type":"ValidationException","message":"The provided model identifier is invalid."}"#,
&converse,
)
.expect_err("converse exception");
let text = err.to_string();
assert!(
text.contains("ValidationException") && text.contains("model identifier"),
"{text}"
);
let kept = decode_response(
Wire::Converse,
br#"{"output":{"message":{"role":"assistant","content":[{"text":"Hi"}]}},"stopReason":"end_turn"}"#,
&converse,
)
.expect("converse completion");
assert!(
kept.iter()
.any(|ev| matches!(ev, IrStreamEvent::TextDelta { text } if text == "Hi")),
"{kept:?}"
);
}
#[test]
fn decode_response_keeps_vendor_message_without_a_visible_leaf() {
fn profile(wire: &str) -> ResolvedProfile {
crate::parse_profile_str(&format!(
"schema_version = 1\nid = \"t\"\nwire = \"{wire}\"\n"
))
.expect("profile")
}
struct Row {
label: &'static str,
wire: Wire,
wire_name: &'static str,
body: &'static str,
error_has: Option<&'static str>,
keep: &'static str,
}
let rows = [
Row {
label: "responses status failed, no output",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","error":{"code":"server_error","message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses empty message shell",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[{"type":"message"}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses empty content array",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"id":"resp_1","object":"response","status":"failed","error":{"code":"server_error","message":"please wait"},"output":[{"id":"msg_1","type":"message","status":"incomplete","role":"assistant","content":[]}]}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses empty output_text",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[{"type":"message","content":[{"type":"output_text","text":""}]}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses output_text beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "Hi",
},
Row {
label: "responses refusal beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"completed","output":[{"type":"message","content":[{"type":"refusal","refusal":"no"}]}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "no",
},
Row {
label: "responses named tool beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"completed","output":[{"type":"function_call","name":"lookup","arguments":"{}"}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "lookup",
},
Row {
label: "responses audio beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"completed","output":[{"type":"output_audio","data":"SUQz"}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "SUQz",
},
Row {
label: "responses empty audio shell",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[{"type":"output_audio"}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses encrypted reasoning beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","error":{"message":"please wait"},"output":[{"type":"reasoning","id":"rs_1","encrypted_content":"enc-xyz"}]}"#,
error_has: None,
keep: "enc-xyz",
},
Row {
label: "responses empty encrypted reasoning",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","error":{"message":"please wait"},"output":[{"type":"reasoning","encrypted_content":""}]}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses reasoning text beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"plan"}]}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "plan",
},
Row {
label: "responses hosted search beside error",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"completed","output":[{"type":"web_search_call","id":"ws_1","action":{"query":"scores"}}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "ws_1",
},
Row {
label: "responses hosted type shell",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[{"type":"web_search_call"}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "responses failed with no message",
wire: Wire::Responses,
wire_name: "responses",
body: r#"{"status":"failed","output":[]}"#,
error_has: None,
keep: "failed",
},
Row {
label: "chat empty choice",
wire: Wire::ChatCompletions,
wire_name: "chat-completions",
body: r#"{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "chat text beside error",
wire: Wire::ChatCompletions,
wire_name: "chat-completions",
body: r#"{"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "Hi",
},
Row {
label: "messages empty text block",
wire: Wire::Messages,
wire_name: "messages",
body: r#"{"content":[{"type":"text","text":""}],"error":{"type":"api_error","message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "messages text beside error",
wire: Wire::Messages,
wire_name: "messages",
body: r#"{"content":[{"type":"text","text":"Hi"}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "Hi",
},
Row {
label: "gemini empty candidate",
wire: Wire::Gemini,
wire_name: "gemini",
body: r#"{"candidates":[{}],"error":{"message":"please wait"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "gemini text beside error",
wire: Wire::Gemini,
wire_name: "gemini",
body: r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}],"error":{"message":"please wait"}}"#,
error_has: None,
keep: "Hi",
},
Row {
label: "converse empty toolUse",
wire: Wire::Converse,
wire_name: "converse",
body: r#"{"output":{"message":{"content":[{"toolUse":{}}]}},"error":{"message":"please wait","type":"server_error"}}"#,
error_has: Some("please wait"),
keep: "",
},
Row {
label: "converse text beside error",
wire: Wire::Converse,
wire_name: "converse",
body: r#"{"output":{"message":{"content":[{"text":"Hi"}]}},"stopReason":"end_turn","error":{"message":"please wait"}}"#,
error_has: None,
keep: "Hi",
},
];
for row in rows {
let profile = profile(row.wire_name);
let unary = decode_response(row.wire, row.body.as_bytes(), &profile);
match row.error_has {
Some(needle) => {
let err = unary.expect_err(row.label);
assert!(err.to_string().contains(needle), "{}: {err}", row.label);
let stream = decode_stream_events(
row.wire,
&RawSse {
event: None,
data: row.body.to_string(),
},
&profile,
);
if let Err(MapError::Invalid(detail)) = stream {
let MapError::Invalid(text) = err else {
panic!(
"{}: frameless stream is Invalid, decode_response is {err}",
row.label
);
};
assert!(
text.contains(&detail),
"{}: decode_response `{text}` must contain stream `{detail}`",
row.label
);
}
}
None => {
let events = unary.expect(row.label);
let shown = format!("{events:?}");
assert!(
shown.contains(row.keep),
"{}: expected `{}` in {shown}",
row.label,
row.keep
);
}
}
}
}
#[test]
fn dest_chat_complete_failed_tool_stays_stop() {
let events = [
IrStreamEvent::ToolCallStart {
id: "call_a".into(),
name: "get_weather".into(),
thought_signature: None,
index: 0,
},
IrStreamEvent::ToolCallArgDelta {
delta: "{\"city\":".into(),
index: 0,
},
IrStreamEvent::FinishReason {
reason: "failed".into(),
vendor: None,
},
];
let mapped = encode_response(Wire::ChatCompletions, &events).expect("encode");
assert_eq!(
mapped
.pointer("/choices/0/finish_reason")
.and_then(Value::as_str),
Some("stop"),
"failed after a partial tool call must stay stop, got {mapped}"
);
assert!(
mapped
.pointer("/choices/0/message/tool_calls/0/function/arguments")
.and_then(Value::as_str)
== Some("{\"city\":"),
"partial arguments stay on the message, got {mapped}"
);
let end = [
IrStreamEvent::ToolCallStart {
id: "call_a".into(),
name: "get_weather".into(),
thought_signature: None,
index: 0,
},
IrStreamEvent::FinishReason {
reason: "end_turn".into(),
vendor: None,
},
];
let mapped = encode_response(Wire::ChatCompletions, &end).expect("encode end_turn");
assert_eq!(
mapped
.pointer("/choices/0/finish_reason")
.and_then(Value::as_str),
Some("tool_calls"),
"end_turn after a tool call must be tool_calls, got {mapped}"
);
let stop = [
IrStreamEvent::ToolCallStart {
id: "call_a".into(),
name: "get_weather".into(),
thought_signature: None,
index: 0,
},
IrStreamEvent::FinishReason {
reason: "stop".into(),
vendor: None,
},
];
let mapped = encode_response(Wire::ChatCompletions, &stop).expect("encode stop");
assert_eq!(
mapped
.pointer("/choices/0/finish_reason")
.and_then(Value::as_str),
Some("tool_calls"),
"stop after a tool call must be tool_calls, got {mapped}"
);
}
#[test]
fn dest_chat_complete_uses_dest_model() {
let events = [IrStreamEvent::TextDelta {
text: "pong".into(),
}];
let mapped = encode_response_with_model(Wire::ChatCompletions, &events, "claude-haiku-4-5")
.expect("encode Chat");
assert_eq!(
mapped.get("model").and_then(Value::as_str),
Some("claude-haiku-4-5"),
"dest Chat complete must keep dest model, got {mapped}"
);
}
#[test]
fn dest_chat_complete_url_citation_reaches_messages_citations() {
let events = [
IrStreamEvent::TextDelta {
text: "See https://example.com for more.".into(),
},
IrStreamEvent::AnnotationAdded {
annotation: json!({
"type": "url_citation",
"url_citation": {
"title": "Example Domain",
"url": "https://example.com"
}
}),
},
];
let mapped = encode_response(Wire::Messages, &events).expect("encode dest Messages");
assert_eq!(
mapped
.pointer("/content/0/citations/0/url")
.and_then(Value::as_str),
Some("https://example.com"),
"dest Messages complete encode must write text citations url, got {mapped}"
);
assert_eq!(
mapped
.pointer("/content/0/citations/0/type")
.and_then(Value::as_str),
Some("web_search_result_location"),
"dest Messages complete citations must be web_search_result_location, got {mapped}"
);
}
#[test]
fn dest_chat_complete_url_citation_reaches_converse_citations_content() {
let events = [
IrStreamEvent::TextDelta {
text: "See https://example.com for more.".into(),
},
IrStreamEvent::AnnotationAdded {
annotation: json!({
"type": "url_citation",
"url": "https://example.com",
"title": "Example Domain"
}),
},
];
let mapped = encode_response(Wire::Converse, &events).expect("encode dest Converse");
assert_eq!(
mapped
.pointer("/output/message/content/0/citationsContent/citations/0/location/web/url")
.and_then(Value::as_str),
Some("https://example.com"),
"dest Converse complete encode must write citationsContent.citations location.web.url, got {mapped}"
);
}
#[test]
fn dest_chat_complete_url_citation_reaches_gemini_grounding() {
let events = [IrStreamEvent::AnnotationAdded {
annotation: json!({
"type": "url_citation",
"url": "https://example.com",
"title": "Example Domain"
}),
}];
let mapped = encode_response(Wire::Gemini, &events).expect("encode dest Gemini");
assert_eq!(
mapped
.pointer("/candidates/0/groundingMetadata/groundingChunks/0/web/uri")
.and_then(Value::as_str),
Some("https://example.com"),
"dest Gemini complete encode must write groundingChunks.web.uri, got {mapped}"
);
}
#[test]
fn dest_converse_complete_refusal_is_assistant_text() {
let events = [IrStreamEvent::RefusalDelta {
text: "nope".into(),
}];
let mapped = encode_response(Wire::Converse, &events).expect("encode dest Converse");
assert_eq!(
mapped
.pointer("/output/message/content/0/text")
.and_then(Value::as_str),
Some("nope"),
"dest Converse complete must keep a refusal as assistant text, got {mapped}"
);
}
#[test]
fn dest_complete_custom_tool_uses_the_stream_slot() {
let events = [
IrStreamEvent::CustomToolCallStart {
id: "call_a".into(),
name: "calc".into(),
index: 0,
},
IrStreamEvent::CustomToolCallInputDelta {
delta: "{\"n\":1}".into(),
index: 0,
},
];
let converse = encode_response(Wire::Converse, &events).expect("encode dest Converse");
assert_eq!(
converse
.pointer("/output/message/content/0/toolUse/name")
.and_then(Value::as_str),
Some("calc"),
"dest Converse complete must write toolUse, got {converse}"
);
assert_eq!(
converse
.pointer("/output/message/content/0/toolUse/input/n")
.and_then(Value::as_i64),
Some(1),
"dest Converse complete must keep custom tool input, got {converse}"
);
let messages = encode_response(Wire::Messages, &events).expect("encode dest Messages");
assert_eq!(
messages.pointer("/content/0/type").and_then(Value::as_str),
Some("tool_use"),
"dest Messages complete must write tool_use, got {messages}"
);
assert_eq!(
messages.pointer("/content/0/name").and_then(Value::as_str),
Some("calc"),
"dest Messages complete must keep the custom tool name, got {messages}"
);
assert_eq!(
messages
.pointer("/content/0/input/n")
.and_then(Value::as_i64),
Some(1),
"dest Messages complete must keep custom tool input, got {messages}"
);
let gemini = encode_response(Wire::Gemini, &events).expect("encode dest Gemini");
assert_eq!(
gemini
.pointer("/candidates/0/content/parts/0/functionCall/name")
.and_then(Value::as_str),
Some("calc"),
"dest Gemini complete must write functionCall, got {gemini}"
);
assert_eq!(
gemini
.pointer("/candidates/0/content/parts/0/functionCall/args/n")
.and_then(Value::as_i64),
Some(1),
"dest Gemini complete must keep custom tool args, got {gemini}"
);
}
#[test]
fn dest_complete_custom_tool_non_json_input_is_raw_object() {
let events = [
IrStreamEvent::CustomToolCallStart {
id: "call_c".into(),
name: "widget".into(),
index: 0,
},
IrStreamEvent::CustomToolCallInputDelta {
delta: "pri".into(),
index: 0,
},
IrStreamEvent::CustomToolCallInputDelta {
delta: "nt(1)".into(),
index: 0,
},
IrStreamEvent::ToolCallEnd,
];
let expected = json!({ "raw": "print(1)" });
let messages = encode_response(Wire::Messages, &events).expect("messages");
assert_eq!(
messages.pointer("/content/0/input"),
Some(&expected),
"Messages complete custom input, got {messages}"
);
let gemini = encode_response(Wire::Gemini, &events).expect("gemini");
assert_eq!(
gemini.pointer("/candidates/0/content/parts/0/functionCall/args"),
Some(&expected),
"Gemini complete custom input, got {gemini}"
);
let converse = encode_response(Wire::Converse, &events).expect("converse");
assert_eq!(
converse.pointer("/output/message/content/0/toolUse/input"),
Some(&expected),
"Converse complete custom input, got {converse}"
);
let function = [
IrStreamEvent::ToolCallStart {
id: "t1".into(),
name: "lookup".into(),
thought_signature: None,
index: 0,
},
IrStreamEvent::ToolCallArgDelta {
delta: "not-json".into(),
index: 0,
},
IrStreamEvent::ToolCallEnd,
];
let function_messages = encode_response(Wire::Messages, &function).expect("function");
assert_eq!(
function_messages.pointer("/content/0/input"),
Some(&json!({})),
"a function tool with invalid JSON stays an empty object, got {function_messages}"
);
let array = [
IrStreamEvent::CustomToolCallStart {
id: "call_c".into(),
name: "widget".into(),
index: 0,
},
IrStreamEvent::CustomToolCallInputDelta {
delta: "[1]".into(),
index: 0,
},
IrStreamEvent::ToolCallEnd,
];
let array_messages = encode_response(Wire::Messages, &array).expect("array");
assert_eq!(
array_messages.pointer("/content/0/input"),
Some(&json!({ "raw": [1] })),
"a JSON array custom input stays inside raw, got {array_messages}"
);
}
#[test]
fn dest_chat_complete_refusal_stays_message_refusal() {
let events = [IrStreamEvent::RefusalDelta {
text: "nope".into(),
}];
let mapped = encode_response(Wire::ChatCompletions, &events).expect("encode dest Chat");
assert_eq!(
mapped
.pointer("/choices/0/message/refusal")
.and_then(Value::as_str),
Some("nope"),
"dest Chat complete encode must write message.refusal, got {mapped}"
);
let content = mapped.pointer("/choices/0/message/content");
assert!(
content.is_none()
|| content.and_then(Value::as_str).is_none_or(str::is_empty)
|| content.is_some_and(Value::is_null),
"dest Chat complete refusal must keep content empty or null, got {mapped}"
);
}
#[test]
fn dest_chat_complete_audio_deltas_concatenate() {
let events = [
IrStreamEvent::AudioDelta {
data: "YQ==".into(),
},
IrStreamEvent::AudioDelta {
data: "Yg==".into(),
},
IrStreamEvent::AudioTranscriptDelta { text: "hel".into() },
IrStreamEvent::AudioTranscriptDelta { text: "lo".into() },
];
let mapped = encode_response(Wire::ChatCompletions, &events).expect("encode dest Chat");
assert_eq!(
mapped
.pointer("/choices/0/message/audio/data")
.and_then(Value::as_str),
Some("YQ==Yg=="),
"dest Chat complete encode must concatenate audio data deltas, got {mapped}"
);
assert_eq!(
mapped
.pointer("/choices/0/message/audio/transcript")
.and_then(Value::as_str),
Some("hello"),
"dest Chat complete encode must concatenate audio transcript deltas, got {mapped}"
);
}
#[test]
fn dest_responses_complete_refusal_is_refusal_part() {
let events = [IrStreamEvent::RefusalDelta {
text: "nope".into(),
}];
let mapped = encode_response(Wire::Responses, &events).expect("encode dest Responses");
let refusal = mapped.pointer("/output/0/content/0");
assert_eq!(
refusal.and_then(|p| p.get("type")).and_then(Value::as_str),
Some("refusal"),
"dest Responses complete encode must emit type refusal, got {mapped}"
);
assert_eq!(
refusal
.and_then(|p| p.get("refusal"))
.and_then(Value::as_str),
Some("nope"),
"dest Responses complete encode must emit refusal text, got {mapped}"
);
}
#[test]
fn dest_responses_complete_usage_maps_cache_write_and_total() {
let events = [IrStreamEvent::Usage {
prompt_tokens: 80,
completion_tokens: 12,
cache_read_tokens: 25,
cache_write_tokens: 9,
reasoning_tokens: 3,
audio_tokens: 0,
completion_audio_tokens: 0,
inference_geo: None,
}];
let mapped = encode_response(Wire::Responses, &events).expect("encode dest Responses");
assert_eq!(
mapped
.pointer("/usage/input_tokens_details/cache_write_tokens")
.and_then(Value::as_u64),
Some(9),
"dest Responses complete usage must emit cache_write_tokens, got {mapped}"
);
assert_eq!(
mapped
.pointer("/usage/total_tokens")
.and_then(Value::as_u64),
Some(120),
"dest Responses complete usage must emit total_tokens, got {mapped}"
);
}
}