Skip to content

feat(ai): freeform tools - #59963

Merged
rekram1-node merged 1 commit into
devfrom
ai-freeform
Oct 28, 2026
Merged

rekram1-node merged 1 commit into
devfrom
ai-freeform

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adds OpenAI-style custom ("freeform") tools to @opencode/ai. A tool with exactly one required string parameter can set freeform, and the model writes that parameter as raw text instead of JSON args:

AI.tool({
  description: "Run JavaScript",
  parameters: Schema.Struct({ code: Schema.String }), // exactly one required string field
  freeform: { type: "lark", grammar: JS_GRAMMAR }, // or { type: "text" } / { type: "regex", pattern }
  execute: ({ code }) => runCode(code),
})

Same on Tool.make. The one-string-field rule is checked at the type level and again when the tool is built. Execution, tool input events, results and history all keep the normal { code } object; freeform is only a wire encoding, carried on ToolDefinition.freeform.

Who encodes it: OpenAI Responses and Chat Completions, so everything that reuses them gets it with no per-provider code: Azure, Bedrock Mantle, Vercel, OpenRouter, Cloudflare AI Gateway, DigitalOcean, Venice, OpenAI-compatible Chat/Responses, plus the xAI/Meta/Alibaba Responses and the other Chat-based providers. Anthropic, Gemini, Bedrock Converse etc. have no freeform code and send the normal JSON tool.

No fallback: the caller decides. A tool with freeform always goes out as a custom tool on those two protocols; if the model doesn't support it you get the provider's error. Core will add or omit freeform per model (from models.dev) in a later PR.

Structure: one shared module both protocols use, and thin wire mapping in each:

protocols/openai-freeform.ts   resolve(tool) · toRaw / fromRaw · streamStart / streamDelta / close · keepsItemID
├─ open-responses.ts           custom / custom_tool_call / custom_tool_call_output, custom_tool_call_input.delta
└─ openai-chat.ts              { type: "custom", custom }, tool_calls[].custom deltas, custom history
  • Streaming: raw deltas are re-encoded as escaped JSON-arg fragments ({"code":"…, closed with "}) through the existing tool-stream code, so consumers see the same events as a JSON tool.
  • History: past calls replay the way the tool is declared in the current request (custom call + raw input, or function call + JSON args), so the same stored history works in both directions. The item id is dropped when the call type changes (fc_ vs ctc_); call_id stays.

Live matrix (ai.llm.loop with an executing tool, all streamed over HTTP SSE; text used a multi-line input with quotes, backslashes, a tab and emoji, and the tool got it byte-for-byte):

Host API text regex lark
OpenAI (gpt-5.4) Responses / Chat ✅ / ✅ ✅ / ✅ ✅ / ✅
Azure (gpt-5.4-nano) Responses / Chat ✅ / ✅ ✅ / ✅ ✅ / ✅
Vercel Responses / Chat ✅ / ✅ ✅ / ✅ ✅ / ✅
Bedrock Mantle (/openai/v1) Responses / Chat ✅ / ✅ ✅ / ✅ ✅ / ✅
Cloudflare AI Gateway Responses ✅ ✅ ✅
DigitalOcean, Venice, OpenRouter Responses (OpenAI-compatible route) ✅ ✅ ✅

Every ✅ means the custom call came back, the tool ran with the right input, and the model read the result. I didn't test Cloudflare Chat because the account's billing balance was too low.

Switching history between models also works: custom-call history replays to Claude as JSON tool_use, back to OpenAI Responses as custom calls, and to the same tool without freeform as a function call.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" font-family="ui-monospace,monospace" font-size="11">
<style>.t{animation:t 3s infinite}.j{animation:j 3s infinite;opacity:0}@keyframes t{0%,45%{opacity:1}55%,100%{opacity:.25}}@keyframes j{0%,45%{opacity:0}55%,100%{opacity:1}}@media (prefers-reduced-motion:reduce){.t,.j{animation:none}.j{opacity:1}}</style>
<rect x="6" y="18" width="88" height="36" rx="8" opacity=".5"/>
<text x="50" y="40" text-anchor="middle" fill="currentColor" stroke="none" class="t">ZZ123</text>
<path d="M100 36h36m-6-5 6 5-6 5" opacity=".6"/>
<rect x="142" y="18" width="92" height="36" rx="8" opacity=".5"/>
<text x="188" y="40" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" class="j">{"code":"ZZ123"}</text>
</svg>

Added by the opencode bot (AI-generated).

Public API: adds an optional freeform option to AI.tool/Tool.make/ToolDefinition and a Freeform export in @opencode/ai (backwards compatible)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sound to me. The new tests and the full packages/ai suite pass, and tsc is clean. A small script of my own showed a freeform tool going out as a custom tool (with a custom tool choice) on Azure Responses, as a normal JSON tool on Anthropic, and being rejected at build time when the parameters aren't exactly one required string. I had no credentials, so I couldn't test against a live model.

Public API (additions in @opencode/ai, backwards compatible): a Freeform schema and type export; an optional freeform option on AI.tool, Tool.make and ToolDefinition; a ToolDefinition.freeformField helper; a FreeformOption type. Tools without freeform are sent exactly as before. The Open Responses helpers whose signatures changed (lowerTools, lowerToolChoice, allowedToolChoice) aren't exported from the package.

No blocking concerns.

@rekram1-node
rekram1-node merged commit 45713a3 into dev Oct 28, 2026
39 of 40 checks passed
@rekram1-node
rekram1-node deleted the ai-freeform branch October 28, 2026 18:21
@amrkmn amrkmn mentioned this pull request Oct 29, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant