sigoden/aichat · error · anyhow::Error
Failed to parse '.messages
Error message
Failed to parse '.messages[{i}]' What it means
parse_messages (src/serve.rs:820) converts each raw JSON element of the `messages` array into the server's typed Message enum. When an element can't be matched to any Message variant — no `role` string, no recognizable `content`, or a tool_call/tool_result shape that fits no variant — it returns anyhow!("Failed to parse '.messages[{i}']") with the failing index. chat_completions surfaces this as a 4xx 'Invalid request body' error.
Solutions
- Give every message a string `role` ("system"|"user"|"assistant"|"tool") and a `content` that is a plain string or the content-part array the server supports
- Remove or repair null/empty content entries in conversation history
- For tool flows, match the exact tool message shape parse_messages expects (see src/serve.rs:820) — usually {"role":"tool","tool_call_id":"...","content":"..."}
- Read the index in the error (e.g. .messages[3]) and dump that specific element to see which field is malformed
Example fix
// before
{"messages": [{"role": "user", "content": null}]}
// after
{"messages": [{"role": "user", "content": "hello"}]} Defensive patterns
Strategy: validation
Validate before calling
function validateMessageElement(m, i) {
const roles = ['system', 'user', 'assistant', 'tool'];
if (typeof m.role !== 'string' || !roles.includes(m.role)) return `.messages[${i}]: role must be one of ${roles.join('|')}`;
if (m.content == null) return `.messages[${i}]: content is required`;
if (typeof m.content !== 'string' && !Array.isArray(m.content)) return `.messages[${i}]: content must be a string or supported array`;
if (m.role === 'tool' && typeof m.tool_call_id !== 'string') return `.messages[${i}]: tool message requires tool_call_id`;
return null;
} Type guard
function isParsableMessage(m) {
const okContent = typeof m.content === 'string' || Array.isArray(m.content);
return typeof m === 'object' && m !== null
&& typeof m.role === 'string' && okContent;
} Try / catch
payload.messages.forEach((m, i) => {
const e = validateMessageElement(m, i);
if (e) throw new Error(`Refusing to send: ${e} — got ${JSON.stringify(m)}`);
});
try {
const res = await fetch('/chat_completions', {method:'POST', body: JSON.stringify(payload)});
if (!res.ok) throw new Error(await res.text());
} catch (e) { /* handle */ } Prevention
- Normalize history from all sources (logs, DB, other providers) to the server's Message shape before sending
- Drop or replace null-content messages instead of forwarding them
- Use one typed message-builder helper across the codebase
- When the server names .messages[i], log that element for immediate diagnosis
When it happens
Trigger: A messages[i] element lacking a string `role` (e.g. {"content": "hi"}), an unrecognized role value, content that is neither a string nor a supported content-part array (e.g. null, number, or an object), or a tool-calling message whose fields (tool_calls / tool_call_id / name) don't match any expected variant.
Common situations: Multi-turn clients appending assistant/tool messages in a format this server doesn't support (e.g. newer OpenAI content-part schemas, Anthropic-style blocks); history logs with null content entries; tool frameworks emitting provider-specific message shapes; truncation that drops the role field of the first message.
Understand the failure class
Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
AI-assisted analysis of sigoden/aichat@82976d349a (2026-09-09).
Data as JSON: /api/errors/c96fd3e21681945c.
Report an issue: GitHub.
Appendix: source
Thrown at src/serve.rs:820
fn ret_err<T: std::fmt::Display>(err: T) -> AppResponse {
let data = json!({
"error": {
"message": err.to_string(),
"type": "invalid_request_error",
},
});
Response::builder()
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(data.to_string())).boxed())
.unwrap()
}
fn parse_messages(message: Vec<Value>) -> Result<Vec<Message>> {
let mut output = vec![];
let mut tool_results = None;
for (i, message) in message.into_iter().enumerate() {
let err = || anyhow!("Failed to parse '.messages[{i}]'");
let role = message["role"].as_str().ok_or_else(err)?;
let content = match message.get("content") {
Some(value) => {
if let Some(value) = value.as_str() {
MessageContent::Text(value.to_string())
} else if value.is_array() {
let value = serde_json::from_value(value.clone()).map_err(|_| err())?;
MessageContent::Array(value)
} else if value.is_null() {
MessageContent::Text(String::new())
} else {
return Err(err());
}
}
None => MessageContent::Text(String::new()),
};
match role {
"system" | "user" => {View on GitHub (pinned to 82976d349a)