Skip to content
Merged
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
68 changes: 61 additions & 7 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1979,14 +1979,67 @@ function normalizeImageGenClientTools(body: unknown): unknown {
* carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
* matches, keeping the common path allocation-free.
*/
function stripUnsupportedHostedTools(body: unknown): unknown {
if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxProviderConfig, "baseUrl">): unknown {
if (!isPlainObject(body)) return body;
const model = typeof body.model === "string" ? body.model : "";
const tools = body.tools.filter(t => {
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
return !type || !isHostedToolUnsupportedForModel(model, type);
});
return tools.length === body.tools.length ? body : { ...body, tools };
const filterTools = (tools: unknown[]): unknown[] => {
const filtered = tools.filter(t => {
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl);
});
return filtered.length === tools.length ? tools : filtered;
};

let next: Record<string, unknown> = body;
let changed = false;
if (Array.isArray(body.tools)) {
const tools = filterTools(body.tools);
if (tools !== body.tools) {
next = { ...next, tools };
changed = true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (Array.isArray(body.input)) {
let inputChanged = false;
const input = body.input.map(item => {
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
const tools = filterTools(item.tools);
if (tools === item.tools) return item;
inputChanged = true;
return { ...item, tools };
});
if (inputChanged) {
next = { ...next, input };
changed = true;
}
}

const toolChoice = next.tool_choice;
if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
const tools = filterTools(toolChoice.tools);
if (tools !== toolChoice.tools) {
next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" };
changed = true;
}
} else if (
isPlainObject(toolChoice)
&& typeof toolChoice.type === "string"
&& isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl)
) {
next = { ...next, tool_choice: "none" };
changed = true;
} else if (changed && toolChoice === "required") {
const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0)
|| (Array.isArray(next.input) && next.input.some(item =>
isPlainObject(item)
&& item.type === "additional_tools"
&& Array.isArray(item.tools)
&& item.tools.length > 0));
if (!hasDeclaredTools) {
next = { ...next, tool_choice: "none" };
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return changed ? next : body;
}

/**
Expand Down Expand Up @@ -2390,6 +2443,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
stripEncryptedContent: threadServingIdentityChanged,
},
),
provider,
),
),
),
Expand Down
6 changes: 4 additions & 2 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1576,7 +1576,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON.
openaiChatEofTolerance: true,
/* [Decision Log]
- 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, and Muse Spark 1.2 Contributor (#2617).
- 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617).
- 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative.
- 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default.
- 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule.
Expand All @@ -1585,6 +1585,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
*/
modelWireDefaults: {
"gpt-5.6-luna": "openai-responses",
"grok-4.6": "openai-responses",
"muse-spark-1.3-contributor": "openai-responses",
"muse-spark-1.2-contributor": "openai-responses",
},
Expand Down Expand Up @@ -1614,6 +1615,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
},
modelReasoningEfforts: {
"gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS,
"grok-4.6": ["low", "medium", "high", "xhigh"],
"glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
"glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
"glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
Expand All @@ -1625,7 +1627,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
},
modelDefaultReasoningEfforts: { "kimi-k3": "max" },
modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" },
// glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
// the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
modelReasoningEffortMap: {
Expand Down
16 changes: 12 additions & 4 deletions src/responses/hosted-tool-policy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
/** Hosted tools rejected by specific native model slugs. */
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
/** Hosted tools rejected by specific native model slugs or exact provider destinations. */
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{
match: (model: string, baseUrl?: string) => boolean;
tools: ReadonlySet<string>;
}> = [
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) },
{
match: (model, baseUrl) => model === "grok-4.6"
&& baseUrl?.replace(/\/+$/, "") === "https://opencode.ai/zen/go/v1",
tools: new Set(["web_search", "web_search_preview"]),
},
];

/** True when forwarding this hosted tool to the model would be rejected upstream. */
export function isHostedToolUnsupportedForModel(modelId: string, tool: string): boolean {
return UNSUPPORTED_HOSTED_TOOLS.some(entry => entry.match(modelId) && entry.tools.has(tool));
export function isHostedToolUnsupportedForModel(modelId: string, tool: string, baseUrl?: string): boolean {
return UNSUPPORTED_HOSTED_TOOLS.some(entry => entry.match(modelId, baseUrl) && entry.tools.has(tool));
}
135 changes: 135 additions & 0 deletions tests/opencode-go-grok46-responses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses";
import { providerConfigSeed } from "../src/providers/derive";
import { getProviderRegistryEntry } from "../src/providers/registry";
import { resolveWireProtocolOverride } from "../src/server/adapter-resolve";
import type { OcxProviderConfig } from "../src/types";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createResponsesPassthroughAdapter = (...args: Parameters<typeof createResponsesPassthroughAdapterProduction>) =>
withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args));

const registryEntry = getProviderRegistryEntry("opencode-go");
if (!registryEntry) throw new Error("missing opencode-go registry fixture");

function provider(baseUrl = "https://opencode.ai/zen/go/v1"): OcxProviderConfig {
return {
...providerConfigSeed(registryEntry),
adapter: "openai-responses",
baseUrl,
apiKey: "test-key",
} as OcxProviderConfig;
}

function build(
modelId: string,
rawBody: Record<string, unknown>,
configuredProvider = provider(),
): Record<string, unknown> {
const request = createResponsesPassthroughAdapter(configuredProvider).buildRequest({
modelId,
context: { messages: [] },
stream: true,
options: {},
_rawBody: { model: modelId, input: "ping", ...rawBody },
}, { headers: new Headers() });
return JSON.parse(request.body) as Record<string, unknown>;
}

describe("OpenCode Go Grok 4.6 Responses compatibility", () => {
test("routes only the documented Grok model to Responses", () => {
const configured = providerConfigSeed(registryEntry);

expect(resolveWireProtocolOverride("opencode-go", "grok-4.6", configured).adapter)
.toBe("openai-responses");
expect(resolveWireProtocolOverride("opencode-go", "grok-4.5", configured).adapter)
.toBe("openai-chat");
});

test("maps a stale Codex max request to Grok's highest supported effort", () => {
const body = build("grok-4.6", { reasoning: { effort: "max" } });

expect(body.reasoning).toEqual({ effort: "xhigh" });
expect(registryEntry.modelReasoningEfforts?.["grok-4.6"])
.toEqual(["low", "medium", "high", "xhigh"]);
expect(registryEntry.modelDefaultReasoningEfforts?.["grok-4.6"]).toBe("high");
});

test("drops the hosted search tool that this exact destination rejects", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
tools: [
{ type: "web_search", search_context_size: "medium" },
{ type: "web_search_preview" },
functionTool,
],
});

expect(body.tools).toEqual([functionTool]);
});

test("drops hosted search from an additional_tools-only request", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
input: [{
type: "additional_tools",
tools: [{ type: "web_search_preview" }, functionTool],
}],
});

expect(body.input).toEqual([{ type: "additional_tools", tools: [functionTool] }]);
});

test("disables an explicit choice for a removed hosted tool", () => {
const body = build("grok-4.6", {
tools: [{ type: "web_search" }],
tool_choice: { type: "web_search" },
});

expect(body.tools).toEqual([]);
expect(body.tool_choice).toBe("none");
});

test("narrows allowed_tools to declarations that remain", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
tools: [{ type: "web_search_preview" }, functionTool],
tool_choice: {
type: "allowed_tools",
mode: "required",
tools: [{ type: "web_search_preview" }, { type: "function", name: "lookup" }],
},
});

expect(body.tools).toEqual([functionTool]);
expect(body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [{ type: "function", name: "lookup" }],
});
});

test("disables required mode when every declared tool is removed", () => {
const body = build("grok-4.6", {
tools: [{ type: "web_search" }],
tool_choice: "required",
});

expect(body.tools).toEqual([]);
expect(body.tool_choice).toBe("none");
});

test("preserves hosted search for another model on OpenCode Go", () => {
const webSearch = { type: "web_search", search_context_size: "medium" };
const body = build("gpt-5.6-luna", { tools: [webSearch] });

expect(body.tools).toEqual([webSearch]);
});

test("preserves hosted search for Grok 4.6 on another destination", () => {
const webSearch = { type: "web_search", search_context_size: "medium" };
const body = build("grok-4.6", { tools: [webSearch] }, provider("https://api.x.ai/v1"));

expect(body.tools).toEqual([{ type: "web_search" }]);
});
});
Loading