1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
use serde::{Deserialize, Serialize};
/// Who authored a [`ChatMessage`].
///
/// Serializes to the lowercase strings the OpenAI chat-completions API expects
/// (`system`, `user`, `assistant`, `tool`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// Operator / system instructions.
System,
/// End-user input.
User,
/// Model output.
Assistant,
/// The result of a tool call, fed back to the model.
Tool,
}
/// A single message in a conversation.
///
/// The field layout mirrors the OpenAI chat-completions wire format so it
/// serializes directly with no conversion layer — which is exactly what
/// OpenRouter and other OpenAI-compatible gateways consume.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessage {
/// Author of the message.
pub role: Role,
/// Text content. `None` for assistant turns that are pure tool calls, or
/// when [`Self::content_parts`] carries multimodal content instead.
pub content: Option<String>,
/// Multimodal content parts (e.g. `{"type":"image_url", ...}` alongside
/// `{"type":"text", ...}`). When present, these are serialized as the wire
/// `content` array (taking precedence over [`Self::content`]) — this is how
/// images/vision input reach a vision model.
pub content_parts: Option<Vec<serde_json::Value>>,
/// Tool calls requested by an assistant turn.
pub tool_calls: Option<Vec<ToolCall>>,
/// For `tool` messages: the id of the [`ToolCall`] this is a result for.
pub tool_call_id: Option<String>,
/// Optional name (used by some providers for tool messages).
pub name: Option<String>,
/// Source-format provenance/labels that have no slot in the OpenAI wire
/// shape (e.g. Codex `phase`, `turn_id`; Claude `promptSource`, `isMeta`,
/// `sourceToolAssistantUUID`). Never serialized — kept only for fidelity and
/// inspection so loading a session doesn't silently discard this signal.
/// (Never serialized — the custom `Serialize` impl omits it.)
pub metadata: std::collections::BTreeMap<String, String>,
}
impl serde::Serialize for ChatMessage {
fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = ser.serialize_map(None)?;
m.serialize_entry("role", &self.role)?;
// Multimodal parts take precedence and serialize as the `content` array.
if let Some(parts) = &self.content_parts {
m.serialize_entry("content", parts)?;
} else if let Some(c) = &self.content {
m.serialize_entry("content", c)?;
}
if let Some(tc) = &self.tool_calls {
m.serialize_entry("tool_calls", tc)?;
}
if let Some(id) = &self.tool_call_id {
m.serialize_entry("tool_call_id", id)?;
}
if let Some(n) = &self.name {
m.serialize_entry("name", n)?;
}
m.end()
}
}
impl<'de> serde::Deserialize<'de> for ChatMessage {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
role: Role,
#[serde(default)]
content: Option<serde_json::Value>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
tool_call_id: Option<String>,
#[serde(default)]
name: Option<String>,
}
let raw = Raw::deserialize(de)?;
// `content` may be a string or a multimodal array.
let (content, content_parts) = match raw.content {
Some(serde_json::Value::String(s)) => (Some(s), None),
Some(serde_json::Value::Array(a)) => (None, Some(a)),
Some(serde_json::Value::Null) | None => (None, None),
Some(other) => (Some(other.to_string()), None),
};
Ok(ChatMessage {
role: raw.role,
content,
content_parts,
tool_calls: raw.tool_calls,
tool_call_id: raw.tool_call_id,
name: raw.name,
metadata: Default::default(),
})
}
}
impl ChatMessage {
/// Build a `system` message.
pub fn system(content: impl Into<String>) -> Self {
Self::text(Role::System, content)
}
/// Build a `user` message with multimodal content — leading text plus one
/// `image_url` part per URL (an `https://…` link or a `data:` URL). This is
/// how images are passed to a vision model.
pub fn user_with_images(text: impl Into<String>, image_urls: &[String]) -> Self {
let mut parts = vec![serde_json::json!({"type": "text", "text": text.into()})];
for url in image_urls {
parts.push(serde_json::json!({"type": "image_url", "image_url": {"url": url}}));
}
ChatMessage {
role: Role::User,
content: None,
content_parts: Some(parts),
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
}
}
/// Build a `user` message.
pub fn user(content: impl Into<String>) -> Self {
Self::text(Role::User, content)
}
/// Build an `assistant` message with plain text.
pub fn assistant(content: impl Into<String>) -> Self {
Self::text(Role::Assistant, content)
}
/// Build a `tool` result message tied to a specific tool call.
pub fn tool_result(
tool_call_id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
ChatMessage {
role: Role::Tool,
content: Some(content.into()),
content_parts: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
name: Some(name.into()),
metadata: Default::default(),
}
}
/// P4c (COMPOSABLE-HARNESS-DESIGN.md §1.2 `core.tools.read_file
/// multimodal` / `view_image`): a `tool` result that carries an image
/// content block alongside a short text notice — `content_parts`
/// (image passthrough) rather than a plain string, so the model
/// actually sees the image. `data_url` is a full `data:image/...;
/// base64,...` URL (see `tools::builtins::image_tool_result`).
pub fn tool_result_with_image(
tool_call_id: impl Into<String>,
name: impl Into<String>,
notice: impl Into<String>,
data_url: impl Into<String>,
) -> Self {
ChatMessage {
role: Role::Tool,
content: None,
content_parts: Some(vec![
serde_json::json!({"type": "text", "text": notice.into()}),
serde_json::json!({"type": "image_url", "image_url": {"url": data_url.into()}}),
]),
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
name: Some(name.into()),
metadata: Default::default(),
}
}
fn text(role: Role, content: impl Into<String>) -> Self {
ChatMessage {
role,
content: Some(content.into()),
content_parts: None,
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
}
}
/// The tool calls on this message, or an empty slice.
pub fn tool_calls(&self) -> &[ToolCall] {
self.tool_calls.as_deref().unwrap_or(&[])
}
/// Attach a metadata key/value, returning `self` (builder style).
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
/// Attach several metadata key/values, returning `self`.
pub fn with_metas(mut self, pairs: &[(String, String)]) -> Self {
for (k, v) in pairs {
self.metadata.insert(k.clone(), v.clone());
}
self
}
}
/// A request from the model to invoke a tool.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
/// Provider-assigned id; the matching `tool` result must echo it.
pub id: String,
/// Always `"function"` in the OpenAI format.
#[serde(rename = "type", default = "default_tool_type")]
pub kind: String,
/// The function name + serialized arguments.
pub function: FunctionCall,
}
/// The function payload of a [`ToolCall`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCall {
/// Tool name.
pub name: String,
/// Arguments as a JSON-encoded string (the wire format the API uses).
pub arguments: String,
}
impl FunctionCall {
/// Parse the JSON-encoded [`Self::arguments`] into a value.
///
/// An empty or whitespace-only argument string is treated as `{}`.
pub fn parsed_arguments(&self) -> serde_json::Result<serde_json::Value> {
let trimmed = self.arguments.trim();
if trimmed.is_empty() {
return Ok(serde_json::Value::Object(Default::default()));
}
serde_json::from_str(trimmed)
}
}
fn default_tool_type() -> String {
"function".to_string()
}