diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 792d4034b3..ac486f5b8a 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -968,8 +968,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (provider.parallelToolCalls === false) { // NIM documents the Boolean defaulting to false and kimi rejects true; pin the // wire bit so Codex cannot opt in via request.options. Other opted-out providers - // omit the field so strict OpenAI-compatible hosts never see an unsupported knob. - if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") { + // omit the field by default so strict OpenAI-compatible hosts never see an + // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps + // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. + if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" + || provider.pinParallelToolCallsFalse === true) { body.parallel_tool_calls = false; } } else if (provider.parallelToolCalls === true) { diff --git a/src/types.ts b/src/types.ts index d24811f430..6d0e84affb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1450,6 +1450,16 @@ export interface OcxProviderConfig { * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls. */ parallelToolCalls?: boolean; + /** + * Opt-in: when `parallelToolCalls` is `false`, actually send `parallel_tool_calls: false` + * on the `/chat/completions` wire for this provider. By default an opted-out provider only + * OMITS the field (strict OpenAI-compatible hosts reject unknown knobs), and the NVIDIA NIM + * baseUrl is the sole built-in exception that pins the wire bit. Some self-hosted gateways + * (Kimi/GLM-family, vLLM, etc.) do honor `parallel_tool_calls` and keep emitting concurrent + * tool calls unless it is present; enable this to pin the bit without hardcoding their URL. + * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters. + */ + pinParallelToolCallsFalse?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/tests/parallel-tool-calls-optin.test.ts b/tests/parallel-tool-calls-optin.test.ts index 2252220cff..5b154e6ba8 100644 --- a/tests/parallel-tool-calls-optin.test.ts +++ b/tests/parallel-tool-calls-optin.test.ts @@ -42,6 +42,24 @@ describe("parallel tool calls provider opt-in (request body)", () => { const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; expect(body).not.toHaveProperty("parallel_tool_calls"); }); + + test("pinParallelToolCallsFalse pins the wire bit for an opted-out non-NVIDIA provider", () => { + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://llm.example.internal/v1", apiKey: "k", parallelToolCalls: false, pinParallelToolCallsFalse: true }); + const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; + expect(body.parallel_tool_calls).toBe(false); + }); + + test("pinParallelToolCallsFalse keeps sending false even under a permissive request bit", () => { + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://llm.example.internal/v1", apiKey: "k", parallelToolCalls: false, pinParallelToolCallsFalse: true }); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; + expect(body.parallel_tool_calls).toBe(false); + }); + + test("pinParallelToolCallsFalse has no effect without parallelToolCalls:false", () => { + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://llm.example.internal/v1", apiKey: "k", pinParallelToolCallsFalse: true }); + const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); }); describe("stale persisted config backfill (router)", () => {