Skip to content

feat: OpenAI-compatible function/tool calling#2

Open
haexhub wants to merge 6 commits into
joesobo:mainfrom
haexhub:feat/openai-tool-calling
Open

feat: OpenAI-compatible function/tool calling#2
haexhub wants to merge 6 commits into
joesobo:mainfrom
haexhub:feat/openai-tool-calling

Conversation

@haexhub
Copy link
Copy Markdown

@haexhub haexhub commented Apr 15, 2026

Summary

Adds OpenAI-compatible function/tool calling across the proxy so agent
frameworks that speak the standard Chat Completions API (Hermes Agent,
Clawdbot in function-calling mode, LangChain OpenAI, etc.) can drive
Claude Code CLI with typed tools.

Previously `request.tools` was silently dropped and the CLI's stdout
was passed through as plain text. The LLM had no way to signal a tool
call, so agent frameworks got a confused text reply instead of a
structured `tool_calls` response.

How it works

No changes to the CLI invocation itself — Claude continues running in
`--print` mode. The protocol lives entirely in the adapter:

Request side (`openai-to-cli.ts`)

  • If the OpenAI request contains `tools`, we prepend a Tool-Use
    Protocol
    section to the prompt: tool definitions, JSON-Schema params,
    and clear rules telling Claude to emit a single `<tool_call>…</tool_call>`
    block with `{name, arguments}` when it wants to call a tool.
  • `tool_choice` (`auto` | `none` | `required` | `{function: {name}}`)
    maps to explicit policy text.
  • `role: "tool"` messages become `<tool_result id="…">…</tool_result>`
    blocks so multi-turn tool conversations re-hydrate correctly.
  • Assistant `tool_calls` from history are echoed back in the same
    `<tool_call>` shape so Claude sees its own prior calls.

Response side (`cli-to-openai.ts`)

  • A new `extractToolCalls()` scans Claude's text for `<tool_call>`
    blocks, parses the inner JSON, and returns OpenAI-format `tool_calls`
    plus clean text.
  • Missing `id` → generated; missing `arguments` → `"{}"`; malformed
    JSON is silently dropped so plain text still reaches the client.
  • `finish_reason` flips to `"tool_calls"` when any calls are parsed.

Routes (`server/routes.ts`) now pass the `hasTools` flag through to
the decoder. Streaming buffers content until end-of-stream when tools
are active (partial JSON is ambiguous to stream incrementally).

Types alignment

`types/openai.ts` is extended to full OpenAI spec: `OpenAITool`,
`OpenAIToolChoice`, `OpenAIToolCall` (+ delta for streaming), nullable
assistant content, `tool` role message, and `"tool_calls"` as a finish
reason.

Tests

14 new tests in `src/adapter/tool-calling.test.ts` cover:

  • Encoder: `hasTools` flag, tools-protocol injection, tool_choice
    variants (`auto` / `none` / `required` / specific function),
    multi-turn `tool` role rendering.
  • Decoder: single-block, multiple-blocks-in-order, missing id/args,
    custom id, malformed JSON, missing name, unterminated block,
    plain-text passthrough.

All pass with `pnpm build && pnpm test`.

Verified end-to-end

Tested with a Hermes Agent (NousResearch) instance talking to the proxy
as `custom:claude-proxy` with 46 registered tools including
`mcp_fwbg_*` tools via a FastMCP stdio server. Round-trip confirmed:
Hermes → proxy → Claude emits `<tool_call>` → decoder returns
`tool_calls` → Hermes executes MCP tool → result round-trips back →
Claude emits final text with the data.

Optional debug logging

`DEBUG=1` env var enables per-request logging: model, tool-count,
message-count, stream flag, tool names, and the full prompt text.
Useful when adding a new agent framework and diagnosing protocol
mismatches.

Backwards compatibility

Requests without `tools` take the original code path — only the
response types are relaxed to allow the new `tool_calls` field
(optional). No existing tests regressed.

haex added 6 commits April 15, 2026 23:29
Translates standard OpenAI chat-completions tools in/out across the Claude
Code CLI boundary so clients like Hermes Agent and any other OpenAI-
compatible agent framework can use typed function calls through the proxy.

Encoder (src/adapter/openai-to-cli.ts):
  * When request.tools is present, prepend a tool-use protocol description
    to the prompt (tool names, JSON-Schema params, tool_choice semantics).
  * Render assistant tool_calls and tool-role messages so Claude sees the
    full round-trip context on multi-turn conversations.
  * Expose TOOL_CALL_OPEN_TAG / TOOL_CALL_CLOSE_TAG for the decoder.
  * openaiToCli() now returns hasTools so the caller knows to run the
    extraction step on the response.

Decoder (src/adapter/cli-to-openai.ts):
  * New extractToolCalls() scans response text for <tool_call>…</tool_call>
    blocks, parses JSON, normalizes missing fields (generated id, empty
    arguments), and returns OpenAI-format tool_calls plus clean text.
  * cliResultToOpenai() / cliToOpenaiChunk() / createDoneChunk() now
    optionally emit finish_reason="tool_calls" when tools were detected.

Types (src/types/openai.ts):
  * Full OpenAI spec alignment: tool / tool_choice / tool_calls /
    OpenAIToolMessage role, nullable assistant content, streaming deltas.

Server (src/server/routes.ts):
  * Both streaming and non-streaming paths detect cliInput.hasTools and
    route through the extractor. Streaming buffers content while tools
    are active so tool_call JSON is only emitted once complete.

Tests (src/adapter/tool-calling.test.ts):
  * 14 tests covering encoder, decoder, tool_choice variants, multi-turn,
    malformed / unterminated blocks, missing name/args, id generation.
Claude was interpreting 'you have access to tools' literally (trying to
execute them) rather than as 'emit JSON requests'. Reword the prompt to:
- Explicitly separate emission from execution
- Forbid meta-commentary ("I'll call X")
- Fall back to plain text when no tool fits
- Avoid hallucinated tool names
nayrosk referenced this pull request in nayrosk/claude-max-api-proxy Apr 21, 2026
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.
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.

1 participant