openclaw - 💡(How to fix) Fix claude-max-api-proxy: `messagesToPrompt` stringifies content-block arrays as `[object Object]` (Sonnet/Opus reply with confused 'you sent an object' messages) [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#74496Fetched 2026-04-30 06:23:32
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
closed ×1commented ×1

@theserverlessdev/claude-max-api-proxy (the proxy referenced from OpenClaw's claude-max provider docs) has a serialization bug in dist/adapter/openai-to-cli.js:messagesToPrompt. When msg.content is a content-block array (the standard OpenAI/Anthropic multimodal format — [{type:"text", text:"hello"}, ...]), the adapter pushes the array directly to a parts array which is later joined with \n. JavaScript stringifies the array via Array.toString(), calling Object.prototype.toString() on each block, producing the literal string "[object Object]". The model receives [object Object] instead of the actual content and honestly reports "you sent an object, what did you mean?" — which then propagates as conversation history.

End-user symptom: Sonnet/Opus "can't read my messages" but third-party providers (qwen/kimi/deepseek/openrouter) work fine. Bug is invisible from gateway-side trajectory logs because gateway sends correct content; the mangling happens inside the proxy's CLI-input adapter.

Error Message

Root Cause

dist/adapter/openai-to-cli.js:

export function messagesToPrompt(messages) {
    const parts = [];
    for (const msg of messages) {
        switch (msg.role) {
            case "system":
                parts.push(`<system>\n${msg.content}\n</system>\n`);  // ← template-stringifies array as [object Object]...
                break;
            case "user":
                parts.push(msg.content);  // ← pushes raw array; .join() then stringifies each element
                break;
            case "assistant":
                parts.push(`<previous_response>\n${msg.content}\n</previous_response>\n`);  // ← same template-stringify bug
                break;
        }
    }
    return parts.join("\n").trim();
}

Three issues, same shape:

  1. ${msg.content} triggers default coercion of arrays/objects to [object Object].
  2. parts.push(msg.content) for user role pushes a raw array; parts.join() then coerces each element to [object Object].
  3. No content-block awareness anywhere — image/audio/tool blocks are silently dropped or mangled.

Fix Action

Fix / Workaround

I applied this patch locally to my own install. Test cases that previously produced [object Object]:

InputBefore patchAfter patch
[{role:"user", content:"Hello plain"}]"Hello plain""Hello plain"
[{role:"user", content:[{type:"text", text:"Hello block"}]}]"[object Object]""Hello block"
[{role:"user", content:[{type:"text", text:"Look"}, {type:"image_url",...}]}]"[object Object][object Object]""Look\n[image]"
[{role:"assistant", content:[{type:"text", text:"I see"}]}]"<previous_response>\n[object Object]\n</previous_response>""<previous_response>\nI see\n</previous_response>"

Live test through OpenClaw → claude-max-api-proxy → claude CLI → Sonnet 4.6 / Opus:

  • Pre-patch: Sonnet replied "you sent [object Object]" complaints
  • Post-patch: clean responses to instructions

Code Example

{
     "model": "claude-sonnet-4",
     "messages": [{"role":"user","content":[{"type":"text","text":"Hello"}]}]
   }

---

export function messagesToPrompt(messages) {
    const parts = [];
    for (const msg of messages) {
        switch (msg.role) {
            case "system":
                parts.push(`<system>\n${msg.content}\n</system>\n`);  // ← template-stringifies array as [object Object]...
                break;
            case "user":
                parts.push(msg.content);  // ← pushes raw array; .join() then stringifies each element
                break;
            case "assistant":
                parts.push(`<previous_response>\n${msg.content}\n</previous_response>\n`);  // ← same template-stringify bug
                break;
        }
    }
    return parts.join("\n").trim();
}

---

function contentToString(content) {
    if (typeof content === "string") return content;
    if (content == null) return "";
    if (!Array.isArray(content)) {
        try { return JSON.stringify(content); } catch { return String(content); }
    }
    return content.map((part) => {
        if (typeof part === "string") return part;
        if (part == null) return "";
        if (part.type === "text") return part.text || "";
        if (part.type === "image_url") return "[image]";
        if (part.type === "input_audio") return "[audio]";
        try { return JSON.stringify(part); } catch { return ""; }
    }).filter((s) => s.length).join("\n");
}

export function messagesToPrompt(messages) {
    const parts = [];
    for (const msg of messages) {
        const text = contentToString(msg.content);
        switch (msg.role) {
            case "system":
                parts.push(`<system>\n${text}\n</system>\n`);
                break;
            case "user":
                parts.push(text);
                break;
            case "assistant":
                parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
                break;
        }
    }
    return parts.join("\n").trim();
}
RAW_BUFFERClick to expand / collapse

Summary

@theserverlessdev/claude-max-api-proxy (the proxy referenced from OpenClaw's claude-max provider docs) has a serialization bug in dist/adapter/openai-to-cli.js:messagesToPrompt. When msg.content is a content-block array (the standard OpenAI/Anthropic multimodal format — [{type:"text", text:"hello"}, ...]), the adapter pushes the array directly to a parts array which is later joined with \n. JavaScript stringifies the array via Array.toString(), calling Object.prototype.toString() on each block, producing the literal string "[object Object]". The model receives [object Object] instead of the actual content and honestly reports "you sent an object, what did you mean?" — which then propagates as conversation history.

End-user symptom: Sonnet/Opus "can't read my messages" but third-party providers (qwen/kimi/deepseek/openrouter) work fine. Bug is invisible from gateway-side trajectory logs because gateway sends correct content; the mangling happens inside the proxy's CLI-input adapter.

Reproduction

  1. Install OpenClaw with claude-max provider pointing at claude-max-api-proxy.
  2. Issue any request whose messages[].content is an array of blocks (the OpenAI/Anthropic multimodal format), e.g.:
    {
      "model": "claude-sonnet-4",
      "messages": [{"role":"user","content":[{"type":"text","text":"Hello"}]}]
    }
  3. Observe Sonnet replying with something like "It looks like you accidentally sent a JavaScript object that got stringified as [object Object]. What did you mean to send?"
  4. The same request with content as a plain string works correctly.

Root cause

dist/adapter/openai-to-cli.js:

export function messagesToPrompt(messages) {
    const parts = [];
    for (const msg of messages) {
        switch (msg.role) {
            case "system":
                parts.push(`<system>\n${msg.content}\n</system>\n`);  // ← template-stringifies array as [object Object]...
                break;
            case "user":
                parts.push(msg.content);  // ← pushes raw array; .join() then stringifies each element
                break;
            case "assistant":
                parts.push(`<previous_response>\n${msg.content}\n</previous_response>\n`);  // ← same template-stringify bug
                break;
        }
    }
    return parts.join("\n").trim();
}

Three issues, same shape:

  1. ${msg.content} triggers default coercion of arrays/objects to [object Object].
  2. parts.push(msg.content) for user role pushes a raw array; parts.join() then coerces each element to [object Object].
  3. No content-block awareness anywhere — image/audio/tool blocks are silently dropped or mangled.

Severity

High. Anyone routing OpenAI-style multimodal-format requests through this proxy will get garbage out for Sonnet/Opus. The mangling is invisible from the calling side because the proxy responds with a normal-looking 200 + assistant text — just text that's a confused complaint about [object Object].

Compounding: the assistant's confused-complaint reply gets cached as conversation history and other models reading the history then "confirm" the bug as a real OpenClaw serialization issue, sending users on wild goose chases.

Suggested fix

Add a content-block-aware coercion helper, apply it everywhere msg.content is used:

function contentToString(content) {
    if (typeof content === "string") return content;
    if (content == null) return "";
    if (!Array.isArray(content)) {
        try { return JSON.stringify(content); } catch { return String(content); }
    }
    return content.map((part) => {
        if (typeof part === "string") return part;
        if (part == null) return "";
        if (part.type === "text") return part.text || "";
        if (part.type === "image_url") return "[image]";
        if (part.type === "input_audio") return "[audio]";
        try { return JSON.stringify(part); } catch { return ""; }
    }).filter((s) => s.length).join("\n");
}

export function messagesToPrompt(messages) {
    const parts = [];
    for (const msg of messages) {
        const text = contentToString(msg.content);
        switch (msg.role) {
            case "system":
                parts.push(`<system>\n${text}\n</system>\n`);
                break;
            case "user":
                parts.push(text);
                break;
            case "assistant":
                parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
                break;
        }
    }
    return parts.join("\n").trim();
}

Verification

I applied this patch locally to my own install. Test cases that previously produced [object Object]:

InputBefore patchAfter patch
[{role:"user", content:"Hello plain"}]"Hello plain""Hello plain"
[{role:"user", content:[{type:"text", text:"Hello block"}]}]"[object Object]""Hello block"
[{role:"user", content:[{type:"text", text:"Look"}, {type:"image_url",...}]}]"[object Object][object Object]""Look\n[image]"
[{role:"assistant", content:[{type:"text", text:"I see"}]}]"<previous_response>\n[object Object]\n</previous_response>""<previous_response>\nI see\n</previous_response>"

Live test through OpenClaw → claude-max-api-proxy → claude CLI → Sonnet 4.6 / Opus:

  • Pre-patch: Sonnet replied "you sent [object Object]" complaints
  • Post-patch: clean responses to instructions

Related

  • openclaw/openclaw#20566 — claude-max provider serialization error (downstream symptom of this bug)
  • Multiple competing proxy forks exist (mattschwen, sethschnrt, rynfar/meridian) — likely all hit the same bug or worked around it differently. Worth coordinating fixes.

Repository

Filing this in openclaw/openclaw because that's where I encountered it through configured documentation. The actual offending package is claude-max-api-proxy (npm, separate repo) — if maintainers want to redirect, happy to refile there.

extent analysis

TL;DR

The most likely fix is to apply a content-block-aware coercion helper to properly handle multimodal-format requests in the claude-max-api-proxy package.

Guidance

  • Identify and update the messagesToPrompt function in dist/adapter/openai-to-cli.js to use the suggested contentToString helper.
  • Verify the fix by testing with various input formats, including plain strings and multimodal-format arrays.
  • Consider coordinating with maintainers of competing proxy forks to ensure consistent fixes.
  • Review related issues, such as openclaw/openclaw#20566, to ensure downstream symptoms are addressed.

Example

The provided contentToString function can be used as a replacement for the existing coercion logic:

function contentToString(content) {
    if (typeof content === "string") return content;
    if (content == null) return "";
    if (!Array.isArray(content)) {
        try { return JSON.stringify(content); } catch { return String(content); }
    }
    return content.map((part) => {
        if (typeof part === "string") return part;
        if (part == null) return "";
        if (part.type === "text") return part.text || "";
        if (part.type === "image_url") return "[image]";
        if (part.type === "input_audio") return "[audio]";
        try { return JSON.stringify(part); } catch { return ""; }
    }).filter((s) => s.length).join("\n");
}

Notes

The provided fix assumes that the contentToString function is correctly implemented and handles all possible input formats. Additional testing and verification may be necessary to ensure the fix is comprehensive.

Recommendation

Apply the suggested workaround by updating the messagesToPrompt function to use the contentToString helper, as it provides a content-block-aware coercion solution for

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING