use super::client::{
ChatChunk, ChatMessage, ChatRequest, ChunkToolCallDelta, StreamOptions, ToolCallFunction,
ToolCallReplay, ToolDecl, ToolDeclFunction,
};
use crate::backend::TokenEventV2;
use inferd_proto::v2::{
ContentBlock, MessageV2, ResolvedV2, RoleV2, StopReasonV2, ToolCallId, UsageV2,
};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum MapperError {
#[error("openai-compat adapter does not support {0} attachments in v0.2")]
AttachmentUnsupported(&'static str),
#[error("openai-compat adapter received an unknown content-block type")]
UnknownContentBlock,
#[error("openai-compat tool_result content must be text only")]
NonTextToolResult,
}
pub(super) fn request_from_resolved(
resolved: &ResolvedV2,
model: &str,
) -> Result<ChatRequest, MapperError> {
if !resolved.attachments.is_empty() {
return Err(MapperError::AttachmentUnsupported("multimodal"));
}
let mut messages = Vec::with_capacity(resolved.messages.len());
for msg in &resolved.messages {
flatten_message(msg, &mut messages)?;
}
let tools = resolved
.tools
.iter()
.map(|t| ToolDecl {
kind: "function".to_string(),
function: ToolDeclFunction {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone(),
},
})
.collect();
Ok(ChatRequest {
model: model.to_string(),
messages,
stream: true,
temperature: resolved.temperature,
top_p: resolved.top_p,
max_tokens: resolved.max_tokens,
tools,
stream_options: Some(StreamOptions {
include_usage: true,
}),
})
}
fn flatten_message(msg: &MessageV2, out: &mut Vec<ChatMessage>) -> Result<(), MapperError> {
let role_str = role_to_str(msg.role);
let mut text_buf = String::new();
let mut tool_calls: Vec<ToolCallReplay> = Vec::new();
let mut tool_results: Vec<(ToolCallId, String)> = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
text_buf.push_str(text);
}
ContentBlock::ToolUse {
tool_call_id,
name,
input,
} => {
tool_calls.push(ToolCallReplay {
id: tool_call_id.as_str().to_string(),
kind: "function".to_string(),
function: ToolCallFunction {
name: name.clone(),
arguments: serde_json::to_string(input)
.unwrap_or_else(|_| "{}".to_string()),
},
});
}
ContentBlock::ToolResult {
tool_call_id,
content,
} => {
let body = tool_result_to_string(content)?;
tool_results.push((tool_call_id.clone(), body));
}
ContentBlock::Image { .. } => {
return Err(MapperError::AttachmentUnsupported("image"));
}
ContentBlock::Audio { .. } => {
return Err(MapperError::AttachmentUnsupported("audio"));
}
ContentBlock::Video { .. } => {
return Err(MapperError::AttachmentUnsupported("video"));
}
ContentBlock::Unknown => {
return Err(MapperError::UnknownContentBlock);
}
}
}
let has_primary = !text_buf.is_empty() || !tool_calls.is_empty();
if has_primary {
out.push(ChatMessage {
role: role_str.to_string(),
content: if text_buf.is_empty() {
None
} else {
Some(text_buf)
},
tool_calls,
tool_call_id: None,
name: None,
});
}
for (id, body) in tool_results {
out.push(ChatMessage {
role: "tool".to_string(),
content: Some(body),
tool_calls: Vec::new(),
tool_call_id: Some(id.as_str().to_string()),
name: None,
});
}
Ok(())
}
fn tool_result_to_string(content: &[ContentBlock]) -> Result<String, MapperError> {
let mut out = String::new();
for block in content {
match block {
ContentBlock::Text { text } => out.push_str(text),
_ => return Err(MapperError::NonTextToolResult),
}
}
Ok(out)
}
fn role_to_str(role: RoleV2) -> &'static str {
match role {
RoleV2::System => "system",
RoleV2::User => "user",
RoleV2::Assistant => "assistant",
}
}
#[derive(Debug, Default)]
struct ToolCallBuffer {
id: Option<String>,
name: String,
arguments: String,
}
#[derive(Debug, Default)]
pub(super) struct ChunkAccumulator {
tool_calls: Vec<ToolCallBuffer>,
usage: Option<UsageV2>,
finish_reason: Option<String>,
}
impl ChunkAccumulator {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn ingest(&mut self, chunk: ChatChunk) -> Vec<TokenEventV2> {
let mut out = Vec::new();
if let Some(u) = chunk.usage {
self.usage = Some(UsageV2 {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
}
for choice in chunk.choices {
if let Some(text) = choice.delta.content
&& !text.is_empty()
{
out.push(TokenEventV2::Text(text));
}
for tc in choice.delta.tool_calls {
self.absorb_tool_call_delta(tc);
}
if let Some(reason) = choice.finish_reason {
self.finish_reason = Some(reason);
}
}
out
}
fn absorb_tool_call_delta(&mut self, delta: ChunkToolCallDelta) {
while self.tool_calls.len() <= delta.index {
self.tool_calls.push(ToolCallBuffer::default());
}
let slot = &mut self.tool_calls[delta.index];
if let Some(id) = delta.id {
slot.id = Some(id);
}
if let Some(func) = delta.function {
if let Some(name) = func.name {
slot.name.push_str(&name);
}
if let Some(args) = func.arguments {
slot.arguments.push_str(&args);
}
}
}
pub(super) fn finalize(mut self) -> Vec<TokenEventV2> {
let mut out = Vec::new();
let stop_reason_hint = self.finish_reason.clone();
let calls = std::mem::take(&mut self.tool_calls);
for buf in calls {
let Some(id) = buf.id else {
continue;
};
let args = if buf.arguments.is_empty() {
"{}".to_string()
} else {
buf.arguments
};
let input: serde_json::Value =
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
out.push(TokenEventV2::ToolUse {
tool_call_id: ToolCallId(id),
name: buf.name,
input,
});
}
let usage = self.usage.unwrap_or(UsageV2 {
input_tokens: 0,
output_tokens: 0,
});
let stop_reason = match stop_reason_hint.as_deref() {
Some("tool_calls") | Some("function_call") => StopReasonV2::ToolUse,
Some("length") => StopReasonV2::MaxTokens,
Some("stop") => StopReasonV2::EndTurn,
None => StopReasonV2::Error,
Some(_) => StopReasonV2::EndTurn,
};
out.push(TokenEventV2::Done { stop_reason, usage });
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openai_compat::client::{
ChunkChoice, ChunkDelta, ChunkToolCallDelta, ChunkToolCallFunctionDelta, ChunkUsage,
};
use inferd_proto::v2::{ContentBlock, MessageV2, RequestV2, RoleV2, Tool};
use serde_json::json;
fn resolved_with_messages(messages: Vec<MessageV2>) -> ResolvedV2 {
RequestV2 {
id: "req-1".into(),
messages,
..Default::default()
}
.resolve()
.unwrap()
}
#[test]
fn text_only_request_round_trips() {
let r = resolved_with_messages(vec![
MessageV2 {
role: RoleV2::System,
content: vec![ContentBlock::Text {
text: "be terse".into(),
}],
},
MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::Text {
text: "hello".into(),
}],
},
]);
let req = request_from_resolved(&r, "test-model").unwrap();
assert_eq!(req.model, "test-model");
assert!(req.stream);
assert_eq!(req.messages.len(), 2);
assert_eq!(req.messages[0].role, "system");
assert_eq!(req.messages[0].content.as_deref(), Some("be terse"));
assert_eq!(req.messages[1].role, "user");
assert_eq!(req.messages[1].content.as_deref(), Some("hello"));
assert!(req.tools.is_empty());
}
#[test]
fn tools_translate_to_function_decls() {
let mut r = resolved_with_messages(vec![MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::Text { text: "go".into() }],
}]);
r.tools = vec![Tool {
name: "lookup".into(),
description: "look something up".into(),
input_schema: json!({"type": "object"}),
}];
let req = request_from_resolved(&r, "m").unwrap();
assert_eq!(req.tools.len(), 1);
assert_eq!(req.tools[0].kind, "function");
assert_eq!(req.tools[0].function.name, "lookup");
}
#[test]
fn assistant_tool_use_replays_as_tool_calls() {
let r = resolved_with_messages(vec![
MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::Text { text: "go".into() }],
},
MessageV2 {
role: RoleV2::Assistant,
content: vec![ContentBlock::ToolUse {
tool_call_id: ToolCallId("call_1".into()),
name: "lookup".into(),
input: json!({"q": "x"}),
}],
},
]);
let req = request_from_resolved(&r, "m").unwrap();
assert_eq!(req.messages.len(), 2);
let asst = &req.messages[1];
assert_eq!(asst.role, "assistant");
assert!(asst.content.is_none());
assert_eq!(asst.tool_calls.len(), 1);
assert_eq!(asst.tool_calls[0].id, "call_1");
assert_eq!(asst.tool_calls[0].function.name, "lookup");
assert_eq!(asst.tool_calls[0].function.arguments, r#"{"q":"x"}"#);
}
#[test]
fn tool_result_emits_tool_role_message() {
let r = resolved_with_messages(vec![
MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::Text { text: "go".into() }],
},
MessageV2 {
role: RoleV2::Assistant,
content: vec![ContentBlock::ToolUse {
tool_call_id: ToolCallId("call_1".into()),
name: "lookup".into(),
input: json!({}),
}],
},
MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::ToolResult {
tool_call_id: ToolCallId("call_1".into()),
content: vec![ContentBlock::Text {
text: "the answer is 42".into(),
}],
}],
},
]);
let req = request_from_resolved(&r, "m").unwrap();
assert_eq!(req.messages.len(), 3);
let tool_msg = &req.messages[2];
assert_eq!(tool_msg.role, "tool");
assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_1"));
assert_eq!(tool_msg.content.as_deref(), Some("the answer is 42"));
}
#[test]
fn image_attachment_block_is_rejected() {
let r = ResolvedV2 {
id: "x".into(),
messages: vec![MessageV2 {
role: RoleV2::User,
content: vec![ContentBlock::Image {
attachment_id: "img-1".into(),
}],
}],
attachments: Vec::new(),
tools: Vec::new(),
temperature: None,
top_p: None,
top_k: None,
max_tokens: None,
stream: None,
};
let err = request_from_resolved(&r, "m").unwrap_err();
assert_eq!(err, MapperError::AttachmentUnsupported("image"));
}
fn chunk_with_text(text: &str) -> ChatChunk {
ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta {
content: Some(text.to_string()),
tool_calls: Vec::new(),
},
finish_reason: None,
}],
usage: None,
}
}
#[test]
fn accumulator_passes_text_through_and_emits_done() {
let mut acc = ChunkAccumulator::new();
let evs = acc.ingest(chunk_with_text("hello"));
assert_eq!(evs.len(), 1);
assert!(matches!(evs[0], TokenEventV2::Text(ref s) if s == "hello"));
let evs = acc.ingest(chunk_with_text(" world"));
assert_eq!(evs.len(), 1);
let last = ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta::default(),
finish_reason: Some("stop".into()),
}],
usage: Some(ChunkUsage {
prompt_tokens: 7,
completion_tokens: 3,
}),
};
let evs = acc.ingest(last);
assert!(evs.is_empty());
let final_evs = acc.finalize();
assert_eq!(final_evs.len(), 1);
match &final_evs[0] {
TokenEventV2::Done { stop_reason, usage } => {
assert_eq!(*stop_reason, StopReasonV2::EndTurn);
assert_eq!(usage.input_tokens, 7);
assert_eq!(usage.output_tokens, 3);
}
other => panic!("expected Done, got {other:?}"),
}
}
#[test]
fn accumulator_assembles_tool_call_across_deltas() {
let mut acc = ChunkAccumulator::new();
acc.ingest(ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta {
content: None,
tool_calls: vec![ChunkToolCallDelta {
index: 0,
id: Some("call_42".into()),
function: Some(ChunkToolCallFunctionDelta {
name: Some("lookup".into()),
arguments: None,
}),
}],
},
finish_reason: None,
}],
usage: None,
});
acc.ingest(ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta {
content: None,
tool_calls: vec![ChunkToolCallDelta {
index: 0,
id: None,
function: Some(ChunkToolCallFunctionDelta {
name: None,
arguments: Some(r#"{"q":"x"#.into()),
}),
}],
},
finish_reason: None,
}],
usage: None,
});
acc.ingest(ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta {
content: None,
tool_calls: vec![ChunkToolCallDelta {
index: 0,
id: None,
function: Some(ChunkToolCallFunctionDelta {
name: None,
arguments: Some(r#"y"}"#.into()),
}),
}],
},
finish_reason: None,
}],
usage: None,
});
acc.ingest(ChatChunk {
choices: vec![ChunkChoice {
delta: ChunkDelta::default(),
finish_reason: Some("tool_calls".into()),
}],
usage: Some(ChunkUsage {
prompt_tokens: 10,
completion_tokens: 5,
}),
});
let evs = acc.finalize();
assert_eq!(evs.len(), 2);
match &evs[0] {
TokenEventV2::ToolUse {
tool_call_id,
name,
input,
} => {
assert_eq!(tool_call_id.as_str(), "call_42");
assert_eq!(name, "lookup");
assert_eq!(input, &json!({"q": "xy"}));
}
other => panic!("expected ToolUse, got {other:?}"),
}
match &evs[1] {
TokenEventV2::Done { stop_reason, usage } => {
assert_eq!(*stop_reason, StopReasonV2::ToolUse);
assert_eq!(usage.output_tokens, 5);
}
other => panic!("expected Done, got {other:?}"),
}
}
#[test]
fn accumulator_treats_missing_finish_reason_as_error() {
let mut acc = ChunkAccumulator::new();
acc.ingest(chunk_with_text("hi"));
let evs = acc.finalize();
assert_eq!(evs.len(), 1);
match &evs[0] {
TokenEventV2::Done { stop_reason, .. } => {
assert_eq!(*stop_reason, StopReasonV2::Error);
}
other => panic!("expected Done, got {other:?}"),
}
}
}