From 3ee1e7c81dcb560b6f7040691dbfaa4b0eff3262 Mon Sep 17 00:00:00 2001 From: djlongy Date: Thu, 1 Oct 2026 08:05:25 +1000 Subject: [PATCH] fix: map StructuredOutput finish to stop, not tool_calls Opencode ends json_schema turns on its internal StructuredOutput tool, so the finish string contains "tool". mapFinishReason previously mapped any finish containing "tool" to tool_calls even when the proxy had already flattened schema JSON into message.content and toolCalls was empty. Vercel AI SDK Output.object only accepts structured results when the unified finish reason is stop, so clients using response_format json_schema (e.g. generateText + Output.object) threw AI_NoOutputGeneratedError despite valid JSON in message.content. Callers already set finish_reason to tool_calls / stop_reason to tool_use when toolCalls.length > 0, so real tool calling is unchanged. Apply the same fix to mapFinishReasonToAnthropic; Gemini already returned STOP. --- index.js | 11 +++++++++-- index.test.js | 15 ++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 1561d4d..5b3d5e4 100644 --- a/index.js +++ b/index.js @@ -817,7 +817,12 @@ function createResponsesApiResponse(result, model) { export function mapFinishReason(finish) { if (!finish) return "stop" if (finish.includes("length")) return "length" - if (finish.includes("tool")) return "tool_calls" + // Opencode ends json_schema / StructuredOutput turns with a finish string that + // contains "tool". Callers already set finish_reason to "tool_calls" when + // proxy-level toolCalls.length > 0. Mapping the internal structured finish to + // tool_calls here leaves message.content JSON with finish_reason tool_calls + // and an empty tool_calls array — Vercel AI SDK Output.object then throws + // AI_NoOutputGeneratedError. Keep "stop" for content-only completions. return "stop" } @@ -1627,7 +1632,9 @@ export function normalizeAnthropicSystem(system) { export function mapFinishReasonToAnthropic(finish) { if (!finish) return "end_turn" if (finish.includes("length")) return "max_tokens" - if (finish.includes("tool")) return "tool_use" + // Same as mapFinishReason: do not map Opencode StructuredOutput finish to + // tool_use when the proxy already flattened schema JSON into text content. + // Real tool_use is still set by createAnthropicResponse when toolCalls.length > 0. return "end_turn" } diff --git a/index.test.js b/index.test.js index c27c36e..ac4b1b8 100644 --- a/index.test.js +++ b/index.test.js @@ -845,9 +845,13 @@ describe("mapFinishReason", () => { assert.equal(mapFinishReason("length"), "length") }) - it("returns 'tool_calls' when finish includes 'tool'", () => { - assert.equal(mapFinishReason("tool_use"), "tool_calls") - assert.equal(mapFinishReason("tool"), "tool_calls") + it("returns 'stop' when finish includes 'tool' (StructuredOutput); real tools use toolCalls.length", () => { + // Opencode json_schema ends on the internal StructuredOutput tool. Mapping + // that finish to tool_calls breaks AI SDK Output.object. Callers force + // tool_calls when proxy-level toolCalls.length > 0. + assert.equal(mapFinishReason("tool_use"), "stop") + assert.equal(mapFinishReason("tool"), "stop") + assert.equal(mapFinishReason("tool-calls"), "stop") }) it("returns 'stop' for unrecognised values", () => { @@ -1580,8 +1584,9 @@ describe("mapFinishReasonToAnthropic", () => { assert.equal(mapFinishReasonToAnthropic("max_length"), "max_tokens") }) - it("returns tool_use when finish includes tool", () => { - assert.equal(mapFinishReasonToAnthropic("tool_use"), "tool_use") + it("returns end_turn when finish includes tool (StructuredOutput); real tools use toolCalls.length", () => { + assert.equal(mapFinishReasonToAnthropic("tool_use"), "end_turn") + assert.equal(mapFinishReasonToAnthropic("tool"), "end_turn") }) it("returns end_turn for unrecognised values", () => {