use super::*;
pub(super) fn translate_message(message: &Message) -> Result<ChatMessageWire> {
let wire = match message {
Message::System(_) => ChatMessageWire {
role: "system".to_string(),
content: Some(MessageContentWire::Text(message.text())),
tool_calls: Vec::new(),
tool_call_id: None,
},
Message::User(user) => ChatMessageWire {
role: "user".to_string(),
content: Some(translate_user_content(&user.content)?),
tool_calls: Vec::new(),
tool_call_id: None,
},
Message::Assistant(assistant) => {
let text = message.text();
let content = if text.is_empty() && !assistant.tool_calls.is_empty() {
None
} else {
Some(MessageContentWire::Text(text))
};
let tool_calls = assistant
.tool_calls
.iter()
.map(|call| {
Ok(ToolCallWire {
id: call.id.clone(),
kind: "function".to_string(),
function: FunctionCallWire {
name: call.name.clone(),
arguments: serde_json::to_string(&call.arguments)?,
},
})
})
.collect::<Result<Vec<_>>>()?;
ChatMessageWire {
role: "assistant".to_string(),
content,
tool_calls,
tool_call_id: None,
}
}
Message::Tool(tool) => ChatMessageWire {
role: "tool".to_string(),
content: Some(MessageContentWire::Text(message.text())),
tool_calls: Vec::new(),
tool_call_id: Some(tool.tool_call_id.clone()),
},
};
Ok(wire)
}
pub(super) fn translate_user_content(blocks: &[ContentBlock]) -> Result<MessageContentWire> {
let has_image = blocks
.iter()
.any(|block| matches!(block, ContentBlock::Image(_)));
if !has_image {
let mut text = String::new();
for block in blocks {
match block {
ContentBlock::Text(t) => text.push_str(t),
ContentBlock::Json(value) => text.push_str(&value.to_string()),
ContentBlock::Image(_) => unreachable!("guarded by has_image"),
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
ContentBlock::ProviderExtension(_) => {
return Err(unrepresentable_block_error());
}
}
}
return Ok(MessageContentWire::Text(text));
}
let mut parts = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
ContentBlock::Text(t) => parts.push(ContentPartWire::Text { text: t.clone() }),
ContentBlock::Json(value) => parts.push(ContentPartWire::Text {
text: value.to_string(),
}),
ContentBlock::Image(image) => parts.push(ContentPartWire::ImageUrl {
image_url: ImageUrlWire {
url: image.url.clone(),
},
}),
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
ContentBlock::ProviderExtension(_) => {
return Err(unrepresentable_block_error());
}
}
}
Ok(MessageContentWire::Parts(parts))
}
pub(super) fn unrepresentable_block_error() -> TinyAgentsError {
TinyAgentsError::Validation(
"OpenAI request cannot represent a provider-extension content block; \
remove it or target the originating provider"
.to_string(),
)
}
pub(super) fn translate_tool_choice(choice: &ToolChoice) -> Value {
match choice {
ToolChoice::Auto => json!("auto"),
ToolChoice::None => json!("none"),
ToolChoice::Required => json!("required"),
ToolChoice::Tool(name) => json!({
"type": "function",
"function": { "name": name }
}),
}
}
pub(super) fn degraded_json_object_format() -> Value {
json!({
"type": "json_schema",
"json_schema": {
"name": "json_object",
"schema": { "type": "object" },
"strict": false,
}
})
}
pub(super) fn translate_response_format(format: &ResponseFormat) -> Option<Value> {
match format {
ResponseFormat::Text => None,
ResponseFormat::JsonObject => Some(json!({ "type": "json_object" })),
ResponseFormat::JsonSchema { name, schema } | ResponseFormat::Auto { name, schema } => {
Some(json!({
"type": "json_schema",
"json_schema": {
"name": name,
"schema": schema,
"strict": true,
}
}))
}
}
}
#[cfg(test)]
pub(super) fn parse_response(value: Value) -> Result<ModelResponse> {
parse_chat_response(value, None)
}
pub(super) fn parse_chat_response(
value: Value,
reasoning_tags: Option<&ReasoningTagExtraction>,
) -> Result<ModelResponse> {
let parsed: ChatCompletionResponse = serde_json::from_value(value.clone())?;
let choice = parsed.choices.into_iter().next().ok_or_else(|| {
TinyAgentsError::Model("openai response contained no choices".to_string())
})?;
let mut content = Vec::new();
let mut reasoning = String::new();
for value in [choice.message.reasoning_content, choice.message.reasoning]
.into_iter()
.flatten()
{
if let Some(fragment) = reasoning_value_text(value) {
reasoning.push_str(&fragment);
}
}
let visible = match (
choice.message.content.filter(|t| !t.is_empty()),
reasoning_tags,
) {
(Some(text), Some(config)) => {
let (visible, inline) = extract_reasoning(config, &text);
if !inline.is_empty() {
if !reasoning.is_empty() {
reasoning.push_str(config.separator());
}
reasoning.push_str(&inline);
}
visible
}
(Some(text), None) => text,
(None, _) => String::new(),
};
if !reasoning.is_empty() {
content.push(ContentBlock::Thinking {
text: reasoning,
signature: None,
});
}
if !visible.is_empty() {
content.push(ContentBlock::Text(visible));
}
let tool_calls = choice
.message
.tool_calls
.into_iter()
.enumerate()
.map(|(index, call)| {
tool_call_from_wire(
"openai response",
index,
&call.id,
&call.function.name,
&call.function.arguments,
)
})
.collect::<Vec<_>>();
let usage = parsed.usage.map(convert_usage);
let message = AssistantMessage {
id: parsed.id,
content,
tool_calls,
usage,
};
Ok(ModelResponse {
message,
usage,
finish_reason: choice.finish_reason,
raw: Some(value),
resolved_model: None,
})
}
pub(super) fn tool_call_id(slot: usize, id: &str) -> String {
if id.is_empty() {
format!("tool-{slot}")
} else {
id.to_string()
}
}
pub(super) fn tool_call_from_wire(
context: &str,
slot: usize,
id: &str,
name: &str,
raw: &str,
) -> ToolCall {
let call_id = tool_call_id(slot, id);
match parse_tool_arguments(raw) {
Ok(arguments) => ToolCall {
id: call_id,
name: name.to_string(),
arguments,
invalid: None,
},
Err(detail) => {
let reason = format!(
"{context} contained invalid JSON arguments for tool call `{call_id}` (`{name}`): {detail}; raw arguments: {raw:?}"
);
ToolCall::invalid(call_id, name, raw, reason)
}
}
}
pub(super) fn parse_tool_arguments(raw: &str) -> std::result::Result<Value, String> {
if raw.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
match serde_json::from_str(raw) {
Ok(value) => Ok(value),
Err(err) => {
if let Some(value) = recover_tool_arguments(raw) {
return Ok(value);
}
Err(err.to_string())
}
}
}
const TOOL_CALL_TEMPLATE_MARKERS: &[&str] = &[
"<|tool_calls_section_end|>",
"<|tool_call_begin|>",
"<|tool_call_end|>",
"<|tool_call|>",
"<|tool_sep|>",
"<tool_call|>",
"</tool_call>",
"<tool_call>",
];
fn recover_tool_arguments(raw: &str) -> Option<Value> {
let stripped = strip_tool_call_markers(raw);
let candidate = stripped.as_deref().unwrap_or(raw);
if stripped.is_some()
&& let Ok(value) = serde_json::from_str::<Value>(candidate)
{
return Some(value);
}
let mut values =
serde_json::Deserializer::from_str(candidate.trim_start()).into_iter::<Value>();
match values.next() {
Some(Ok(value @ Value::Object(_))) => Some(value),
_ => None,
}
}
fn strip_tool_call_markers(raw: &str) -> Option<String> {
let mut cleaned = raw.to_string();
let mut changed = false;
for &marker in TOOL_CALL_TEMPLATE_MARKERS {
if cleaned.contains(marker) {
cleaned = cleaned.replace(marker, "");
changed = true;
}
}
if !changed {
return None;
}
let trimmed = cleaned.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(super) fn convert_usage(wire: UsageWire) -> Usage {
let total_tokens = if wire.total_tokens > 0 {
wire.total_tokens
} else {
wire.prompt_tokens + wire.completion_tokens
};
Usage {
input_tokens: wire.prompt_tokens,
output_tokens: wire.completion_tokens,
total_tokens,
cache_read_tokens: wire
.prompt_tokens_details
.map(|d| d.cached_tokens)
.unwrap_or(0),
reasoning_tokens: wire
.completion_tokens_details
.map(|d| d.reasoning_tokens)
.unwrap_or(0),
..Usage::default()
}
}
pub(super) fn reasoning_value_text(value: Value) -> Option<String> {
match value {
Value::String(text) => (!text.is_empty()).then_some(text),
Value::Object(map) => ["text", "content", "summary"]
.into_iter()
.find_map(|key| map.get(key).and_then(Value::as_str))
.filter(|text| !text.is_empty())
.map(str::to_string),
Value::Array(values) => {
let text = values
.into_iter()
.filter_map(reasoning_value_text)
.collect::<String>();
(!text.is_empty()).then_some(text)
}
_ => None,
}
}
pub(super) fn delta_reasoning_text(delta: &mut ChunkDeltaWire) -> String {
let mut text = String::new();
for value in [delta.reasoning_content.take(), delta.reasoning.take()]
.into_iter()
.flatten()
{
if let Some(fragment) = reasoning_value_text(value) {
text.push_str(&fragment);
}
}
text
}