#[cfg(feature = "language-model-request")]
use futures::Stream;
#[cfg(feature = "language-model-request")]
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(feature = "language-model-request")]
use std::collections::HashMap;
#[cfg(feature = "language-model-request")]
use uuid;
#[cfg(feature = "language-model-request")]
use crate::core::LanguageModelStreamChunkType;
#[cfg(feature = "language-model-request")]
use crate::error::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum VercelUIStream {
#[serde(rename = "text-start")]
TextStart {
id: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "text-delta")]
TextDelta {
id: String,
delta: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "text-end")]
TextEnd {
id: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "reasoning-start")]
ReasoningStart {
id: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "reasoning-delta")]
ReasoningDelta {
id: String,
delta: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "reasoning-end")]
ReasoningEnd {
id: String,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
},
#[serde(rename = "tool-input-start")]
ToolInputStart {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "providerExecuted")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
#[serde(rename = "tool-input-delta")]
ToolInputDelta {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "inputTextDelta")]
input_text_delta: String,
},
#[serde(rename = "tool-input-available")]
ToolInputAvailable {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
input: Value,
#[serde(rename = "providerExecuted")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
#[serde(rename = "tool-input-error")]
ToolInputError {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
input: Value,
#[serde(rename = "providerExecuted")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(rename = "providerMetadata")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic: Option<bool>,
#[serde(rename = "errorText")]
error_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
#[serde(rename = "tool-output-available")]
ToolOutputAvailable {
#[serde(rename = "toolCallId")]
tool_call_id: String,
output: Value,
#[serde(rename = "providerExecuted")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
preliminary: Option<bool>,
},
#[serde(rename = "tool-output-error")]
ToolOutputError {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "errorText")]
error_text: String,
#[serde(rename = "providerExecuted")]
#[serde(skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic: Option<bool>,
},
#[serde(rename = "error")]
Error {
#[serde(rename = "errorText")]
error_text: String,
},
#[serde(rename = "not-supported")]
NotSupported {
error_text: String,
},
}
#[derive(Default)]
pub struct VercelUIStreamOptions {
pub send_reasoning: bool,
pub send_start: bool,
pub send_finish: bool,
pub generate_message_id: Option<Box<VercelUIStreamIdGenerator>>,
}
pub type VercelUIStreamIdGenerator = dyn Fn() -> String + Send + Sync;
pub struct VercelUIStreamBuilder<C, T> {
pub context: C,
pub options: VercelUIStreamOptions,
build_fn: Box<dyn Fn(C, VercelUIStreamOptions) -> T + Send + Sync>,
}
impl<C, T> VercelUIStreamBuilder<C, T> {
pub fn new<B>(context: C, build_fn: B) -> Self
where
B: Fn(C, VercelUIStreamOptions) -> T + Send + Sync + 'static,
{
Self {
context,
options: VercelUIStreamOptions::default(),
build_fn: Box::new(build_fn),
}
}
pub fn send_reasoning(mut self) -> Self {
self.options.send_reasoning = true;
self
}
pub fn send_start(mut self) -> Self {
self.options.send_start = true;
self
}
pub fn send_finish(mut self) -> Self {
self.options.send_finish = true;
self
}
pub fn with_id_generator<G>(mut self, generator: G) -> Self
where
G: Fn() -> String + Send + Sync + 'static,
{
self.options.generate_message_id = Some(Box::new(generator));
self
}
pub fn build(self) -> T {
(self.build_fn)(self.context, self.options)
}
}
#[cfg(feature = "language-model-request")]
impl crate::core::StreamTextResponse {
pub fn into_vercel_ui_stream(
self,
options: VercelUIStreamOptions,
) -> impl Stream<Item = crate::Result<VercelUIStream>> {
let message_id = options
.generate_message_id
.as_ref()
.map(|f| f())
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
let mut pending_tool_inputs: HashMap<String, String> = HashMap::new();
self.stream.flat_map(move |chunk| {
let ui_chunks = map_language_model_chunk_to_vercel_ui(
chunk,
&message_id,
&options,
&mut pending_tool_inputs,
);
futures::stream::iter(ui_chunks.into_iter().map(Ok))
})
}
}
#[cfg(feature = "language-model-request")]
fn map_language_model_chunk_to_vercel_ui(
chunk: LanguageModelStreamChunkType,
message_id: &str,
options: &VercelUIStreamOptions,
pending_tool_inputs: &mut HashMap<String, String>,
) -> Vec<VercelUIStream> {
match chunk {
LanguageModelStreamChunkType::TextStart => {
if options.send_start {
vec![VercelUIStream::TextStart {
id: message_id.to_string(),
provider_metadata: None,
}]
} else {
Vec::new()
}
}
LanguageModelStreamChunkType::TextDelta(delta) => vec![VercelUIStream::TextDelta {
id: message_id.to_string(),
delta,
provider_metadata: None,
}],
LanguageModelStreamChunkType::TextEnd => {
if options.send_finish {
vec![VercelUIStream::TextEnd {
id: message_id.to_string(),
provider_metadata: None,
}]
} else {
Vec::new()
}
}
LanguageModelStreamChunkType::ReasoningStart => {
if options.send_reasoning && options.send_start {
vec![VercelUIStream::ReasoningStart {
id: message_id.to_string(),
provider_metadata: None,
}]
} else {
Vec::new()
}
}
LanguageModelStreamChunkType::ReasoningDelta(delta) => {
if options.send_reasoning {
vec![VercelUIStream::ReasoningDelta {
id: message_id.to_string(),
delta,
provider_metadata: None,
}]
} else {
Vec::new()
}
}
LanguageModelStreamChunkType::ReasoningEnd => {
if options.send_reasoning && options.send_finish {
vec![VercelUIStream::ReasoningEnd {
id: message_id.to_string(),
provider_metadata: None,
}]
} else {
Vec::new()
}
}
LanguageModelStreamChunkType::ToolCallStart(tool_call) => {
pending_tool_inputs.insert(tool_call.id.clone(), String::new());
vec![VercelUIStream::ToolInputStart {
tool_call_id: tool_call.id,
tool_name: tool_call.name,
provider_executed: Some(true),
provider_metadata: None,
dynamic: None,
title: None,
}]
}
LanguageModelStreamChunkType::ToolCallDelta { id, delta } => {
pending_tool_inputs
.entry(id.clone())
.or_default()
.push_str(&delta);
vec![VercelUIStream::ToolInputDelta {
tool_call_id: id,
input_text_delta: delta,
}]
}
LanguageModelStreamChunkType::ToolCallAvailable(tool_call) => {
pending_tool_inputs.remove(&tool_call.tool.id);
vec![VercelUIStream::ToolInputAvailable {
tool_call_id: tool_call.tool.id,
tool_name: tool_call.tool.name,
input: tool_call.input,
provider_executed: Some(true),
provider_metadata: None,
dynamic: None,
title: None,
}]
}
LanguageModelStreamChunkType::ToolCallEnd(result_info) => {
let tool_call_id = result_info.tool.id.clone();
pending_tool_inputs.remove(&tool_call_id);
let output_chunk = match result_info.output {
Ok(output) => VercelUIStream::ToolOutputAvailable {
tool_call_id,
output,
provider_executed: Some(true),
dynamic: None,
preliminary: None,
},
Err(error) => VercelUIStream::ToolOutputError {
tool_call_id,
error_text: format_tool_error_text(error),
provider_executed: Some(true),
dynamic: None,
},
};
vec![output_chunk]
}
LanguageModelStreamChunkType::Failed(error)
| LanguageModelStreamChunkType::Incomplete(error)
| LanguageModelStreamChunkType::NotSupported(error) => {
vec![VercelUIStream::Error { error_text: error }]
}
}
}
#[cfg(feature = "language-model-request")]
fn format_tool_error_text(error: Error) -> String {
match error {
Error::MissingField(message)
| Error::InvalidInput(message)
| Error::ToolCallError(message)
| Error::PromptError(message)
| Error::Other(message) => message,
Error::ApiError { details, .. } => details,
Error::ProviderError(error) => error.to_string(),
}
}
#[derive(Deserialize, Debug)]
pub struct VercelUIMessagePart {
#[serde(default)]
pub text: Option<String>,
#[serde(rename = "type")]
pub part_type: String,
#[serde(rename = "toolCallId")]
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub input: Option<Value>,
#[serde(default)]
pub output: Option<Value>,
#[serde(rename = "rawInput")]
#[serde(default)]
pub raw_input: Option<Value>,
#[serde(rename = "errorText")]
#[serde(default)]
pub error_text: Option<String>,
#[serde(rename = "toolName")]
#[serde(default)]
pub tool_name: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct VercelUIMessage {
pub id: String,
pub role: String,
pub parts: Vec<VercelUIMessagePart>,
}
#[derive(Deserialize, Debug)]
pub struct VercelUIRequest {
pub id: String,
pub messages: Vec<VercelUIMessage>,
pub trigger: String,
}
impl crate::core::Message {
pub fn from_vercel_ui_message(
ui_messages: &[VercelUIMessage],
) -> crate::core::messages::Messages {
ui_messages
.iter()
.flat_map(|msg| match msg.role.as_str() {
"system" => {
let content = msg
.parts
.iter()
.filter(|part| part.part_type == "text")
.filter_map(|part| part.text.clone())
.collect::<Vec<_>>()
.join("");
if content.is_empty() {
Vec::new()
} else {
vec![crate::core::messages::Message::System(content.into())]
}
}
"user" => {
let content = msg
.parts
.iter()
.filter(|part| part.part_type == "text")
.filter_map(|part| part.text.clone())
.collect::<Vec<_>>()
.join("");
if content.is_empty() {
Vec::new()
} else {
vec![crate::core::messages::Message::User(content.into())]
}
}
"assistant" => msg
.parts
.iter()
.flat_map(VercelUIMessagePart::to_core_messages)
.collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect()
}
}
impl VercelUIMessagePart {
fn to_core_messages(&self) -> Vec<crate::core::messages::Message> {
if self.part_type == "text" {
return self
.text
.as_ref()
.filter(|text| !text.is_empty())
.map(|text| {
vec![crate::core::messages::Message::Assistant(
text.clone().into(),
)]
})
.unwrap_or_default();
}
if !self.part_type.starts_with("tool-") && self.part_type != "dynamic-tool" {
return Vec::new();
}
let Some(tool_call_id) = self.tool_call_id.clone() else {
return Vec::new();
};
let Some(state) = self.state.as_deref() else {
return Vec::new();
};
if state == "input-streaming" {
return Vec::new();
}
let tool_name = self.tool_name();
let input = self
.input
.clone()
.or_else(|| self.raw_input.clone())
.unwrap_or(Value::Null);
let mut tool_call = crate::core::ToolCallInfo::new(tool_name.clone());
tool_call.id(tool_call_id.clone());
tool_call.input(input.clone());
let mut messages = vec![crate::core::messages::Message::Assistant(
crate::core::messages::AssistantMessage::new(
crate::core::language_model::LanguageModelResponseContentType::ToolCall(tool_call),
None,
),
)];
match state {
"input-available" | "approval-requested" | "approval-responded" => messages,
"output-available" => {
let mut result = crate::core::ToolResultInfo::new(tool_name);
result.id(tool_call_id);
result.output(self.output.clone().unwrap_or(Value::Null));
messages.push(crate::core::messages::Message::Tool(result));
messages
}
"output-error" => {
let mut result = crate::core::ToolResultInfo::new(tool_name);
result.id(tool_call_id);
result.output(self.error_output_value());
messages.push(crate::core::messages::Message::Tool(result));
messages
}
"output-denied" => {
let mut result = crate::core::ToolResultInfo::new(tool_name);
result.id(tool_call_id);
result.output(Value::String(
self.error_text
.clone()
.unwrap_or_else(|| "Tool execution denied.".to_string()),
));
messages.push(crate::core::messages::Message::Tool(result));
messages
}
_ => Vec::new(),
}
}
fn tool_name(&self) -> String {
if let Some(tool_name) = &self.tool_name {
return tool_name.clone();
}
self.part_type
.strip_prefix("tool-")
.unwrap_or(&self.part_type)
.to_string()
}
fn error_output_value(&self) -> Value {
self.error_text
.clone()
.map(Value::String)
.unwrap_or(Value::Null)
}
}
impl From<VercelUIRequest> for Vec<crate::core::messages::Message> {
fn from(request: VercelUIRequest) -> Self {
crate::core::messages::Message::from_vercel_ui_message(&request.messages)
}
}
#[cfg(all(test, feature = "language-model-request"))]
mod tests {
use super::*;
use crate::core::LanguageModelStreamChunkType;
use crate::core::messages::Message;
use crate::core::tools::{ToolCallInfo, ToolDetails, ToolResultInfo};
use crate::error::Error;
use serde_json::json;
#[test]
fn serializes_tool_stream_chunks_with_current_ai_sdk_protocol() {
let options = VercelUIStreamOptions::default();
let mut pending_tool_inputs = HashMap::new();
let start_chunks = map_language_model_chunk_to_vercel_ui(
LanguageModelStreamChunkType::ToolCallStart(ToolDetails {
id: "call_1".to_string(),
name: "get_weather".to_string(),
}),
"msg_1",
&options,
&mut pending_tool_inputs,
);
let delta_chunks = map_language_model_chunk_to_vercel_ui(
LanguageModelStreamChunkType::ToolCallDelta {
id: "call_1".to_string(),
delta: "{\"location\":\"dc\"}".to_string(),
},
"msg_1",
&options,
&mut pending_tool_inputs,
);
let available_chunks = map_language_model_chunk_to_vercel_ui(
LanguageModelStreamChunkType::ToolCallAvailable(ToolCallInfo {
tool: ToolDetails {
id: "call_1".to_string(),
name: "get_weather".to_string(),
},
input: json!({
"location": "dc",
}),
extensions: Default::default(),
}),
"msg_1",
&options,
&mut pending_tool_inputs,
);
let mut result_info = ToolResultInfo::new("get_weather");
result_info.id("call_1");
result_info.output(json!("The weather in dc is sunny"));
let end_chunks = map_language_model_chunk_to_vercel_ui(
LanguageModelStreamChunkType::ToolCallEnd(result_info),
"msg_1",
&options,
&mut pending_tool_inputs,
);
assert_eq!(
serde_json::to_value(&start_chunks[0]).unwrap(),
json!({
"type": "tool-input-start",
"toolCallId": "call_1",
"toolName": "get_weather",
"providerExecuted": true,
})
);
assert_eq!(
serde_json::to_value(&delta_chunks[0]).unwrap(),
json!({
"type": "tool-input-delta",
"toolCallId": "call_1",
"inputTextDelta": "{\"location\":\"dc\"}",
})
);
assert_eq!(
serde_json::to_value(&available_chunks[0]).unwrap(),
json!({
"type": "tool-input-available",
"toolCallId": "call_1",
"toolName": "get_weather",
"input": {
"location": "dc",
},
"providerExecuted": true,
})
);
assert_eq!(
serde_json::to_value(&end_chunks[0]).unwrap(),
json!({
"type": "tool-output-available",
"toolCallId": "call_1",
"output": "The weather in dc is sunny",
"providerExecuted": true,
})
);
}
#[test]
fn serializes_tool_output_errors_with_error_text() {
let options = VercelUIStreamOptions::default();
let mut pending_tool_inputs =
HashMap::from([("call_2".to_string(), "{\"location\":\"dc\"}".to_string())]);
let result_info = ToolResultInfo {
tool: ToolDetails {
id: "call_2".to_string(),
name: "get_weather".to_string(),
},
output: Err(Error::Other("weather service unavailable".to_string())),
};
let chunks = map_language_model_chunk_to_vercel_ui(
LanguageModelStreamChunkType::ToolCallEnd(result_info),
"msg_1",
&options,
&mut pending_tool_inputs,
);
assert_eq!(
serde_json::to_value(&chunks[0]).unwrap(),
json!({
"type": "tool-output-error",
"toolCallId": "call_2",
"errorText": "weather service unavailable",
"providerExecuted": true,
})
);
}
#[test]
fn deserializes_assistant_tool_parts_without_text_field() {
let request: VercelUIRequest = serde_json::from_value(json!({
"id": "chat_1",
"trigger": "submit-message",
"messages": [
{
"id": "assistant_1",
"role": "assistant",
"parts": [
{
"type": "tool-get_weather",
"toolCallId": "call_1",
"state": "output-available",
"input": { "location": "dc" },
"output": "The weather in dc is sunny",
"providerExecuted": true
},
{
"type": "text",
"text": "The weather in dc is sunny."
}
]
}
]
}))
.expect("request should deserialize");
let messages: Vec<Message> = request.into();
assert_eq!(messages.len(), 3);
assert!(matches!(
&messages[0],
Message::Assistant(assistant)
if matches!(
&assistant.content,
crate::core::language_model::LanguageModelResponseContentType::ToolCall(tool_call)
if tool_call.tool.id == "call_1"
&& tool_call.tool.name == "get_weather"
&& tool_call.input == json!({ "location": "dc" })
)
));
assert!(matches!(
&messages[1],
Message::Tool(tool_result)
if tool_result.tool.id == "call_1"
&& tool_result.tool.name == "get_weather"
&& tool_result.output.as_ref().ok() == Some(&json!("The weather in dc is sunny"))
));
assert!(matches!(
&messages[2],
Message::Assistant(assistant)
if matches!(
&assistant.content,
crate::core::language_model::LanguageModelResponseContentType::Text(text)
if text == "The weather in dc is sunny."
)
));
}
}