fix: inject thinking param for OpenAI-compatible APIs with reasoning_effort

When reasoning_effort is set, OpenAI-compatible APIs like VolcEngine
default "thinking" to "disabled", causing a 400 error. This adds a
fetch wrapper that injects thinking: { type: "enabled" } into
request bodies that contain reasoning_effort but lack a thinking field.

Standard OpenAI endpoints silently ignore unknown body fields,
so this is safe for all OpenAI-compatible providers.

Closes #11395
This commit is contained in:
Roo Code 2026-02-11 06:14:42 +00:00
parent dcb33c47ad
commit 5ead2da5a0
2 changed files with 118 additions and 1 deletions

View file

@ -68,7 +68,7 @@ vi.mock("axios", () => ({
},
}))
import { OpenAiHandler, getOpenAiModels } from "../openai"
import { OpenAiHandler, getOpenAiModels, createThinkingAwareFetch } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import { openAiModelInfoSaneDefaults } from "@roo-code/types"
@ -143,6 +143,7 @@ describe("OpenAiHandler", () => {
expect.objectContaining({
baseURL: "https://api.openai.com/v1",
apiKey: "test-api-key",
fetch: expect.any(Function),
}),
)
})
@ -978,3 +979,90 @@ describe("getOpenAiModels", () => {
expect(result).toEqual(["gpt-4", "gpt-3.5-turbo"])
})
})
describe("createThinkingAwareFetch", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it("should inject thinking: { type: 'enabled' } when reasoning_effort is present", async () => {
let capturedBody: string | undefined
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response(JSON.stringify({}), { status: 200 })
}) as any
const wrappedFetch = createThinkingAwareFetch()
const body = JSON.stringify({ model: "some-model", reasoning_effort: "high" })
await wrappedFetch("https://example.com/v1/chat/completions", { method: "POST", body })
const parsed = JSON.parse(capturedBody!)
expect(parsed.thinking).toEqual({ type: "enabled" })
expect(parsed.reasoning_effort).toBe("high")
})
it("should not inject thinking when reasoning_effort is absent", async () => {
let capturedBody: string | undefined
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response(JSON.stringify({}), { status: 200 })
}) as any
const wrappedFetch = createThinkingAwareFetch()
const body = JSON.stringify({ model: "some-model", messages: [] })
await wrappedFetch("https://example.com/v1/chat/completions", { method: "POST", body })
const parsed = JSON.parse(capturedBody!)
expect(parsed.thinking).toBeUndefined()
})
it("should not overwrite existing thinking parameter", async () => {
let capturedBody: string | undefined
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response(JSON.stringify({}), { status: 200 })
}) as any
const wrappedFetch = createThinkingAwareFetch()
const body = JSON.stringify({
model: "some-model",
reasoning_effort: "high",
thinking: { type: "disabled", budget_tokens: 0 },
})
await wrappedFetch("https://example.com/v1/chat/completions", { method: "POST", body })
const parsed = JSON.parse(capturedBody!)
expect(parsed.thinking).toEqual({ type: "disabled", budget_tokens: 0 })
})
it("should pass through non-JSON bodies unchanged", async () => {
let capturedBody: string | undefined
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response("ok", { status: 200 })
}) as any
const wrappedFetch = createThinkingAwareFetch()
const body = "not-json-body"
await wrappedFetch("https://example.com/v1/chat/completions", { method: "POST", body })
expect(capturedBody).toBe("not-json-body")
})
it("should pass through requests with no body", async () => {
globalThis.fetch = vi.fn(async () => {
return new Response("ok", { status: 200 })
}) as any
const wrappedFetch = createThinkingAwareFetch()
await wrappedFetch("https://example.com/v1/models")
expect(globalThis.fetch).toHaveBeenCalledWith("https://example.com/v1/models", undefined)
})
})

View file

@ -81,6 +81,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
baseURL,
apiKey,
headers,
fetch: createThinkingAwareFetch(),
})
this.languageModelFactory = (modelId: string) => provider.chat(modelId)
}
@ -373,6 +374,34 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}
/**
* Creates a fetch wrapper that injects `thinking: { type: "enabled" }` into
* request bodies that already contain `reasoning_effort`. This fixes
* OpenAI-compatible APIs (e.g. VolcEngine) that default `thinking` to
* `disabled` when it's not explicitly provided, causing a 400 error when
* combined with `reasoning_effort`.
*
* Standard OpenAI endpoints silently ignore unrecognised body fields,
* so this is safe for all OpenAI-compatible providers.
*/
/** @internal Exported for testing. */
export function createThinkingAwareFetch(): typeof globalThis.fetch {
return async (input: RequestInfo | URL, init?: RequestInit) => {
if (init?.body && typeof init.body === "string") {
try {
const json = JSON.parse(init.body)
if (json.reasoning_effort && !json.thinking) {
json.thinking = { type: "enabled" }
init = { ...init, body: JSON.stringify(json) }
}
} catch {
// Not JSON pass through untouched.
}
}
return globalThis.fetch(input, init)
}
}
export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record<string, string>) {
try {
if (!baseUrl) {