Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join, resolve } from "node:path";

import {
buildBifrostSource,
buildGoSource,
buildOpenAISource,
buildXaiSource,
buildProviderCatalog,
Expand Down Expand Up @@ -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({
Expand Down
17 changes: 10 additions & 7 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions src/provider/openai-compatible-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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" }] },
Expand Down
7 changes: 5 additions & 2 deletions src/provider/openai-compatible-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import { createOpenAIAdapter } from "@intx/inference/providers";
// adapter for every source corbits builds.
type AdapterSource = Parameters<typeof createOpenAIAdapter>[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
Expand Down
1 change: 1 addition & 0 deletions tests/unit/inference-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
9 changes: 9 additions & 0 deletions vendor/intx-inference/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions vendor/intx-inference/src/providers/openai.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions vendor/intx-inference/src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -65,6 +69,7 @@ type ResolvedOpenAIQuirks = {
forceAssistantReasoningContent: boolean;
reasoningFieldNames: readonly ReasoningField[];
maxTokensField: "max_tokens" | "max_completion_tokens";
normalizeNullToolCalls: boolean;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>)["choices"];
if (Array.isArray(choices)) {
for (const choice of choices) {
if (choice === null || typeof choice !== "object") continue;
const delta = (choice as Record<string, unknown>)["delta"];
if (delta === null || typeof delta !== "object") continue;
if ((delta as Record<string, unknown>)["tool_calls"] === null) {
Reflect.deleteProperty(delta, "tool_calls");
}
}
}
}

const chunk = OpenAIChunk(parsed);
if (chunk instanceof type.errors) {
throw new ProtocolMismatchError(
Expand Down Expand Up @@ -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
Expand All @@ -1097,6 +1119,7 @@ export function createOpenAIAdapter(
indexer,
source,
resolvedQuirks.reasoningFieldNames,
resolvedQuirks.normalizeNullToolCalls,
),
parseJSONResponse: (body) =>
parseJSONResponse(body, source, resolvedQuirks.reasoningFieldNames),
Expand Down
Loading