Skip to content

feat: OpenAI tool calling + array/object content fix#1

Merged
nayrosk merged 2 commits into
mainfrom
fix/content-parts-array
Apr 21, 2026
Merged

feat: OpenAI tool calling + array/object content fix#1
nayrosk merged 2 commits into
mainfrom
fix/content-parts-array

Conversation

@nayrosk

@nayrosk nayrosk commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Ports two upstream PRs from joesobo/claude-max-api-proxy onto this fork, merged so both fixes coexist.

  • joesobo#3 — handle array/object content in messagesToPrompt (fixes the [object Object] bug when clients send OpenAI multimodal-style content: [{type:"text",...}]).
  • joesobo#2 — full OpenAI-compatible function/tool calling (tools, tool_choice, tool_calls, role=tool, finish_reason=tool_calls).

Encoder (src/adapter/openai-to-cli.ts)

  • Injects a Tool-Use Protocol system section when the request carries tools: tool list, JSON-Schema params, emission rules, tool_choice policy.
  • tool_choice → auto / none / required / specific function mapped to explicit policy text.
  • Assistant tool_calls replayed as <tool_call>…</tool_call>; role=tool rendered as <tool_result id="…">…</tool_result> for multi-turn re-hydration.
  • contentToString from PR#3 retained and now called on every role branch (system/user/assistant) so array/multimodal content keeps working alongside the tool flow.

Decoder (src/adapter/cli-to-openai.ts)

  • extractToolCalls scans Claude's text for <tool_call> blocks, parses inner JSON, returns OpenAI tool_calls + cleaned text. Robust to missing id/arguments, malformed JSON, and unterminated blocks.
  • cliResultToOpenai / cliToOpenaiChunk accept extractTools flag; finish_reason flips to "tool_calls" when any call is parsed.

Routes (src/server/routes.ts)

  • Passes hasTools through to the decoder.
  • Streaming buffers content while tools are active (partial JSON is unsafe to stream piece-by-piece) and emits the parsed tool_calls in the final chunk.
  • Per-request timing logs ([Timing rid=…]) + opt-in DEBUG=1 prompt dump for diagnosing new agent frameworks.

Types (src/types/openai.ts)

Full alignment with OpenAI Chat Completions v1 spec:

  • Discriminated OpenAIChatMessage union (system | user | assistant | tool).
  • OpenAITool, OpenAIToolChoice, OpenAIToolCall, OpenAIToolCallDelta, OpenAIFinishReason (incl. "tool_calls").
  • Nullable assistant content, OpenAIToolMessage.tool_call_id.
  • OpenAIContentPart retained from PR#3; system/user/assistant content widened to string | OpenAIContentPart[] (assistant also | null).

Tests

14 new cases in src/adapter/tool-calling.test.ts:

  • Encoder: hasTools flag, protocol injection, tool_choice variants (auto/none/required/specific), multi-turn tool role rendering.
  • Decoder: single block, multiple blocks in order, missing id/args, custom id, generated id, malformed JSON, missing name, unterminated block, plain-text passthrough.
✔ 14 pass / 0 fail  (npm test)
✔ tsc clean         (npm run build)

Test plan

  • npm install && npm run build — clean, no type errors.
  • npm test — 14/14 pass.
  • PR#3 content-parts path still exercised (array content reaches all message branches via contentToString).
  • Manual curl end-to-end against a running proxy with a real OpenAI-tool-calling client (optional).

Notes

  • Commit is GPG-signed.
  • package-lock.json picked up a name re-sync (upstream rename from claude-code-cli-providerclaude-max-api-proxy) during npm install.

byungsker and others added 2 commits April 21, 2026 01:27
OpenAI Chat Completions `content` can be either a string or a content-parts
array (e.g. `[{ type: "text", text: "..." }, { type: "image_url", ... }]`).
`messagesToPrompt` previously interpolated `msg.content` directly into template
literals, so the array form stringified via `Array#toString` -> each element's
default `Object#toString` -> `"[object Object]"` separated by commas.

Downstream clients that send structured content (e.g. OpenClaw's inbound
message pipeline) end up feeding Claude CLI prompts like
`[object Object],[object Object]`, and the model naturally responds about
"receiving [object Object] messages".

Introduce a `contentToString` helper and thread it through `messagesToPrompt`:

- strings pass through unchanged
- array parts: extract `text` for text parts, `"[image]"` placeholder for
  `image_url` parts (Claude CLI --print mode is text-only), JSON.stringify
  for unknown shapes so no info is silently dropped
- null/undefined -> empty string (and filtered out before join)
- bare objects -> JSON.stringify as a defensive fallback

Also widen `OpenAIChatMessage.content` to the union type permitted by the
OpenAI spec, so TypeScript consumers stop having to narrow manually.
Apply upstream PR #2 on top of existing content-parts fix (PR joesobo#3).

Encoder (openai-to-cli):
- Inject Tool-Use Protocol into system prompt when request has tools.
- Map tool_choice (auto/none/required/specific) to explicit policy text.
- Render role=tool as <tool_result id="..."> and assistant tool_calls as
  <tool_call> blocks so multi-turn tool flows re-hydrate.
- Preserve contentToString from PR joesobo#3 and apply it across all branches so
  array/multimodal content keeps working alongside the new flow.

Decoder (cli-to-openai):
- extractToolCalls scans Claude's text for <tool_call> blocks, returns
  OpenAI tool_calls plus cleaned text. Robust to missing id/args,
  malformed JSON, and unterminated blocks.
- cliResultToOpenai / cliToOpenaiChunk accept extractTools flag and flip
  finish_reason to "tool_calls" when any call is parsed.

Routes: pass hasTools through, buffer stream content when tools active
(partial JSON unsafe to stream), emit parsed tool_calls in final chunk.
Adds per-request timing logs + opt-in DEBUG=1 prompt dump.

Types: full OpenAI v1 alignment — discriminated message union, tool /
tool_choice / tool_call / delta, nullable assistant content, "tool_calls"
finish reason. OpenAIContentPart kept and widened across system / user /
assistant content.

Tests: 14 new cases in src/adapter/tool-calling.test.ts cover encoder
(hasTools, protocol injection, tool_choice variants, multi-turn) and
decoder (single/multiple blocks, missing id/args, custom id, malformed,
unterminated, plain-text passthrough). All pass.
@nayrosk nayrosk merged commit f1467f6 into main Apr 21, 2026
@nayrosk nayrosk deleted the fix/content-parts-array branch April 21, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants