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

  1. 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
  2. Remove or repair null/empty content entries in conversation history
  3. For tool flows, match the exact tool message shape parse_messages expects (see src/serve.rs:820) — usually {"role":"tool","tool_call_id":"...","content":"..."}
  4. 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

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.

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)