diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index dd0a29cb3bf..297f3a980da 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -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): 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 = body; + let changed = false; + if (Array.isArray(body.tools)) { + const tools = filterTools(body.tools); + if (tools !== body.tools) { + next = { ...next, tools }; + changed = true; + } + } + 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" }; + } + } + return changed ? next : body; } /** @@ -2390,6 +2443,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): stripEncryptedContent: threadServingIdentityChanged, }, ), + provider, ), ), ), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index fd00ba82770..51db40c498c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -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. @@ -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", }, @@ -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, @@ -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: { diff --git a/src/responses/hosted-tool-policy.ts b/src/responses/hosted-tool-policy.ts index 17607a873f1..e9a99c24934 100644 --- a/src/responses/hosted-tool-policy.ts +++ b/src/responses/hosted-tool-policy.ts @@ -1,9 +1,17 @@ -/** Hosted tools rejected by specific native model slugs. */ -const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet }> = [ +/** 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; +}> = [ { 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)); } diff --git a/tests/opencode-go-grok46-responses.test.ts b/tests/opencode-go-grok46-responses.test.ts new file mode 100644 index 00000000000..175e29c3135 --- /dev/null +++ b/tests/opencode-go-grok46-responses.test.ts @@ -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) => + 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, + configuredProvider = provider(), +): Record { + 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; +} + +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" }]); + }); +});