diff --git a/src/config.test.ts b/src/config.test.ts index 1dedfb626..be5dfdc8b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { buildBifrostSource, + buildGoSource, buildOpenAISource, buildXaiSource, buildProviderCatalog, @@ -1178,6 +1179,19 @@ describe("loadConfig", () => { }); }); +describe("buildGoSource", () => { + test("enables null tool_calls normalization for chat-completions models", () => { + const source = buildGoSource({ + id: "opencode-go", + apiKey: "sk-go", + model: "kimi-k2.7-code", + }); + + expect(source.provider).toBe("openai-compatible"); + expect(source.quirks).toEqual({ normalizeNullToolCalls: true }); + }); +}); + describe("buildOpenAISource", () => { test("normalizes the runtime source baseURL", () => { const source = buildOpenAISource({ diff --git a/src/config/index.ts b/src/config/index.ts index 25c831353..dd5b48c84 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -372,13 +372,16 @@ export function buildGoSource(fields: { }; } // chat-completions (default) - return buildOpenAISource({ - id: fields.id, - baseURL: endpoint.baseURL.length > 0 ? endpoint.baseURL : OPENCODE_GO_BASE_URL, - apiKey, - model: fields.model, - ...(fields.reasoningEffort !== undefined ? { reasoningEffort: fields.reasoningEffort } : {}), - }); + return { + ...buildOpenAISource({ + id: fields.id, + baseURL: endpoint.baseURL.length > 0 ? endpoint.baseURL : OPENCODE_GO_BASE_URL, + apiKey, + model: fields.model, + ...(fields.reasoningEffort !== undefined ? { reasoningEffort: fields.reasoningEffort } : {}), + }), + quirks: { normalizeNullToolCalls: true }, + }; } export interface Config { diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 29f628820..0b89a8583 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test"; +import { ProtocolMismatchError } from "@intx/inference"; import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; @@ -80,6 +81,38 @@ describe("openai-compatible adapter SSE parse count", () => { }); }); +describe("openai-compatible adapter null tool_calls quirk", () => { + const nullToolCallsChunk = JSON.stringify({ + choices: [{ index: 0, delta: { content: "hello", tool_calls: null } }], + }); + + test("normalizes null tool_calls when explicitly enabled", () => { + const adapter = createOpenAICompatibleAdapter(source, { normalizeNullToolCalls: true }); + + expect(adapter.parseResponse(nullToolCallsChunk)).toContainEqual({ + type: "inference.text.delta", + seq: 0, + data: { token: "hello", partial: { text: "" }, index: 0 }, + }); + }); + + test("rejects null tool_calls by default", () => { + const adapter = createOpenAICompatibleAdapter(source); + expect(() => adapter.parseResponse(nullToolCallsChunk)).toThrow(ProtocolMismatchError); + }); + + test("rejects malformed non-null tool_calls with the quirk enabled", () => { + const adapter = createOpenAICompatibleAdapter(source, { normalizeNullToolCalls: true }); + expect(() => + adapter.parseResponse( + JSON.stringify({ + choices: [{ index: 0, delta: { content: "hello", tool_calls: "invalid" } }], + }), + ), + ).toThrow(ProtocolMismatchError); + }); +}); + describe("openai-compatible adapter reasoning_content handling", () => { const withThinking: ConversationTurn[] = [ { role: "user", content: [{ type: "text", text: "hi" }] }, diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index e752d9794..987275979 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -15,8 +15,11 @@ import { createOpenAIAdapter } from "@intx/inference/providers"; // adapter for every source corbits builds. type AdapterSource = Parameters[0]; -export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAdapter { - const base = createOpenAIAdapter(source); +export function createOpenAICompatibleAdapter( + source: AdapterSource, + quirks?: unknown, +): ProviderAdapter { + const base = createOpenAIAdapter(source, quirks); // Set by buildRequest for the model the current request targets; only // DeepSeek/NIM streams need the null-delta-field patch below, so every // other provider's frames skip the reparse and hit base.parseResponse diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index 6276c6d78..e5b4e8811 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -179,6 +179,7 @@ test("buildInferenceSourceForRef routes OpenCode Go models by protocol", () => { undefined, ); expect(chat?.provider).toBe("openai-compatible"); + expect(chat?.quirks).toEqual({ normalizeNullToolCalls: true }); expect(chat?.baseURL).toBe("https://opencode.ai/zen/go/v1"); expect(chat?.model).toBe("kimi-k2.7-code"); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 3defd6145..b4d5263bc 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -87,6 +87,15 @@ on the live stream. See `isCommitting` and the docblock on `runInference`. above. Classifies which events count as commitment (everything except pre-commit metadata). +## providers-openai-ts-null-tool-calls-quirk + +`providers/openai.ts` — Adds the opt-in `normalizeNullToolCalls` OpenAI quirk. +Some OpenAI-compatible chat-completions APIs emit `delta.tool_calls: null` +to represent an absent tool-call delta. Opted-in sources normalize only that +field/value to absence before strict chunk validation; the default parser and +all non-null malformed values remain strict. Verified against upstream main at +`ee17074a`, which still rejects null and has no equivalent quirk. + ## reactor-ts-ephemeral-turns `reactor.ts` — `ExtendedInferenceOptions.ephemeralTurns`: turns appended to diff --git a/vendor/intx-inference/src/providers/openai.test.ts b/vendor/intx-inference/src/providers/openai.test.ts new file mode 100644 index 000000000..6d073fb9e --- /dev/null +++ b/vendor/intx-inference/src/providers/openai.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +import { ProtocolMismatchError } from "../errors"; +import { createOpenAIAdapter } from "./openai"; + +const source = { + sourceId: "test-openai", + provider: "openai-compatible", + model: "test-model", +}; + +const nullToolCallsChunk = JSON.stringify({ + choices: [{ index: 0, delta: { content: "hello", tool_calls: null } }], +}); + +describe("OpenAI null tool_calls response quirk", () => { + test("normalizes null tool_calls to absence when enabled", () => { + const adapter = createOpenAIAdapter(source, { normalizeNullToolCalls: true }); + + expect(adapter.parseResponse(nullToolCallsChunk)).toContainEqual({ + type: "inference.text.delta", + seq: 0, + data: { token: "hello", partial: { text: "" }, index: 0 }, + }); + }); + + test("rejects null tool_calls by default", () => { + const adapter = createOpenAIAdapter(source); + + expect(() => adapter.parseResponse(nullToolCallsChunk)).toThrow(ProtocolMismatchError); + }); + + test("rejects malformed non-null tool_calls when enabled", () => { + const adapter = createOpenAIAdapter(source, { normalizeNullToolCalls: true }); + const malformedChunk = JSON.stringify({ + choices: [{ index: 0, delta: { content: "hello", tool_calls: "invalid" } }], + }); + + expect(() => adapter.parseResponse(malformedChunk)).toThrow(ProtocolMismatchError); + }); +}); diff --git a/vendor/intx-inference/src/providers/openai.ts b/vendor/intx-inference/src/providers/openai.ts index 428d2d481..8efbba2d8 100644 --- a/vendor/intx-inference/src/providers/openai.ts +++ b/vendor/intx-inference/src/providers/openai.ts @@ -46,6 +46,10 @@ export const OpenAIQuirks = type({ // through the same adapter (e.g. OpenCode Zen) still take `max_tokens`. // Defaults to `max_tokens` so every existing deployment is unchanged. "maxTokensField?": "'max_tokens' | 'max_completion_tokens'", + // Locally patched — see vendor/intx-inference/PATCHES.md#providers-openai-ts-null-tool-calls-quirk + // Some compatible APIs encode an absent delta.tool_calls as null. Keep the + // strict default and normalize that single value only for opted-in sources. + "normalizeNullToolCalls?": "boolean", // Reject unknown keys so a mistyped quirk name fails loudly at construction // rather than being silently ignored and running with default behavior. "+": "reject", @@ -65,6 +69,7 @@ type ResolvedOpenAIQuirks = { forceAssistantReasoningContent: boolean; reasoningFieldNames: readonly ReasoningField[]; maxTokensField: "max_tokens" | "max_completion_tokens"; + normalizeNullToolCalls: boolean; }; // --------------------------------------------------------------------------- @@ -585,6 +590,7 @@ function parseResponse( indexer: OpenAIBlockIndexer, source: LastCycleSource, reasoningFieldNames: readonly ReasoningField[], + normalizeNullToolCalls: boolean, ): InferenceEvent[] { // parseSSE strips the `[DONE]` sentinel before yielding payloads, so // anything that reaches us here is supposed to be a JSON chunk. A @@ -607,6 +613,21 @@ function parseResponse( ); } + // Locally patched — see vendor/intx-inference/PATCHES.md#providers-openai-ts-null-tool-calls-quirk + if (normalizeNullToolCalls && parsed !== null && typeof parsed === "object") { + const choices = (parsed as Record)["choices"]; + if (Array.isArray(choices)) { + for (const choice of choices) { + if (choice === null || typeof choice !== "object") continue; + const delta = (choice as Record)["delta"]; + if (delta === null || typeof delta !== "object") continue; + if ((delta as Record)["tool_calls"] === null) { + Reflect.deleteProperty(delta, "tool_calls"); + } + } + } + } + const chunk = OpenAIChunk(parsed); if (chunk instanceof type.errors) { throw new ProtocolMismatchError( @@ -1075,6 +1096,7 @@ export function createOpenAIAdapter( reasoningFieldNames: parsedQuirks.reasoningFieldNames ?? DEFAULT_REASONING_FIELDS, maxTokensField: parsedQuirks.maxTokensField ?? "max_tokens", + normalizeNullToolCalls: parsedQuirks.normalizeNullToolCalls ?? false, }; // Per-request indexer state. Adapter instances are created per @@ -1097,6 +1119,7 @@ export function createOpenAIAdapter( indexer, source, resolvedQuirks.reasoningFieldNames, + resolvedQuirks.normalizeNullToolCalls, ), parseJSONResponse: (body) => parseJSONResponse(body, source, resolvedQuirks.reasoningFieldNames),