mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: align Undici headersTimeout with apiRequestTimeout for OpenAI-compatible providers
Fixes #12244. The OpenAI SDK timeout option only controls the SDK-level request timeout but does not override Undici default headersTimeout of 300s. This causes premature HeadersTimeoutError for slow providers like LM Studio when time-to-first-byte exceeds 5 minutes. Creates a shared createFetchWithUndiciTimeout() utility that builds a custom fetch function backed by an Undici Agent with headersTimeout and bodyTimeout aligned to the configured apiRequestTimeout. Applied to all three provider paths that create OpenAI clients: lm-studio, openai (generic OpenAI-compatible), and base-openai-compatible-provider.
This commit is contained in:
parent
ad25634905
commit
81d0e0382f
8 changed files with 180 additions and 0 deletions
|
|
@ -9,7 +9,14 @@ vitest.mock("../utils/timeout-config", () => ({
|
|||
getApiRequestTimeout: vitest.fn(),
|
||||
}))
|
||||
|
||||
// Mock the undici-fetch utility
|
||||
const mockFetchFn = vitest.fn()
|
||||
vitest.mock("../utils/undici-fetch", () => ({
|
||||
createFetchWithUndiciTimeout: vitest.fn(() => mockFetchFn),
|
||||
}))
|
||||
|
||||
import { getApiRequestTimeout } from "../utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "../utils/undici-fetch"
|
||||
|
||||
// Mock OpenAI and capture constructor calls
|
||||
const mockOpenAIConstructor = vitest.fn()
|
||||
|
|
@ -65,6 +72,7 @@ describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => {
|
|||
new TestOpenAiCompatibleProvider("test-api-key")
|
||||
|
||||
expect(getApiRequestTimeout).toHaveBeenCalled()
|
||||
expect(createFetchWithUndiciTimeout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should pass the default timeout to the OpenAI client constructor", () => {
|
||||
|
|
@ -77,6 +85,7 @@ describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => {
|
|||
baseURL: "https://test.example.com/v1",
|
||||
apiKey: "test-api-key",
|
||||
timeout: 600000,
|
||||
fetch: mockFetchFn,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ vitest.mock("../utils/timeout-config", () => ({
|
|||
getApiRequestTimeout: vitest.fn(),
|
||||
}))
|
||||
|
||||
// Mock the undici-fetch utility
|
||||
const mockFetchFn = vitest.fn()
|
||||
vitest.mock("../utils/undici-fetch", () => ({
|
||||
createFetchWithUndiciTimeout: vitest.fn(() => mockFetchFn),
|
||||
}))
|
||||
|
||||
import { getApiRequestTimeout } from "../utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "../utils/undici-fetch"
|
||||
|
||||
// Mock OpenAI
|
||||
const mockOpenAIConstructor = vitest.fn()
|
||||
|
|
@ -45,11 +52,13 @@ describe("LmStudioHandler timeout configuration", () => {
|
|||
new LmStudioHandler(options)
|
||||
|
||||
expect(getApiRequestTimeout).toHaveBeenCalled()
|
||||
expect(createFetchWithUndiciTimeout).toHaveBeenCalled()
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
apiKey: "noop",
|
||||
timeout: 600000, // 600 seconds in milliseconds
|
||||
fetch: mockFetchFn,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ vitest.mock("../utils/timeout-config", () => ({
|
|||
getApiRequestTimeout: vitest.fn(),
|
||||
}))
|
||||
|
||||
// Mock the undici-fetch utility
|
||||
const mockFetchFn = vitest.fn()
|
||||
vitest.mock("../utils/undici-fetch", () => ({
|
||||
createFetchWithUndiciTimeout: vitest.fn(() => mockFetchFn),
|
||||
}))
|
||||
|
||||
import { getApiRequestTimeout } from "../utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "../utils/undici-fetch"
|
||||
|
||||
// Mock OpenAI and AzureOpenAI
|
||||
const mockOpenAIConstructor = vitest.fn()
|
||||
|
|
@ -57,11 +64,13 @@ describe("OpenAiHandler timeout configuration", () => {
|
|||
new OpenAiHandler(options)
|
||||
|
||||
expect(getApiRequestTimeout).toHaveBeenCalled()
|
||||
expect(createFetchWithUndiciTimeout).toHaveBeenCalled()
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
apiKey: "test-key",
|
||||
timeout: 600000, // 600 seconds in milliseconds
|
||||
fetch: mockFetchFn,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -102,6 +111,7 @@ describe("OpenAiHandler timeout configuration", () => {
|
|||
expect(mockAzureOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 900000, // 900 seconds in milliseconds
|
||||
fetch: mockFetchFn,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { BaseProvider } from "./base-provider"
|
|||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { calculateApiCostOpenAI } from "../../shared/cost"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "./utils/undici-fetch"
|
||||
|
||||
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
|
||||
providerName: string
|
||||
|
|
@ -64,6 +65,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
apiKey: this.options.apiKey,
|
||||
defaultHeaders: DEFAULT_HEADERS,
|
||||
timeout: getApiRequestTimeout(),
|
||||
fetch: createFetchWithUndiciTimeout(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { BaseProvider } from "./base-provider"
|
|||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { getModelsFromCache } from "./fetchers/modelCache"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "./utils/undici-fetch"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
|
||||
export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
|
|
@ -34,6 +35,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
|
||||
apiKey: apiKey,
|
||||
timeout: getApiRequestTimeout(),
|
||||
fetch: createFetchWithUndiciTimeout(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { DEFAULT_HEADERS } from "./constants"
|
|||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { createFetchWithUndiciTimeout } from "./utils/undici-fetch"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
|
||||
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
|
||||
|
|
@ -49,6 +50,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
|
||||
const timeout = getApiRequestTimeout()
|
||||
const fetchWithTimeout = createFetchWithUndiciTimeout()
|
||||
|
||||
if (isAzureAiInference) {
|
||||
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
|
||||
|
|
@ -58,6 +60,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
defaultHeaders: headers,
|
||||
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
|
||||
timeout,
|
||||
fetch: fetchWithTimeout,
|
||||
})
|
||||
} else if (isAzureOpenAi) {
|
||||
// Azure API shape slightly differs from the core API shape:
|
||||
|
|
@ -68,6 +71,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: headers,
|
||||
timeout,
|
||||
fetch: fetchWithTimeout,
|
||||
})
|
||||
} else {
|
||||
this.client = new OpenAI({
|
||||
|
|
@ -75,6 +79,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
apiKey,
|
||||
defaultHeaders: headers,
|
||||
timeout,
|
||||
fetch: fetchWithTimeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
src/api/providers/utils/__tests__/undici-fetch.spec.ts
Normal file
95
src/api/providers/utils/__tests__/undici-fetch.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// npx vitest run api/providers/utils/__tests__/undici-fetch.spec.ts
|
||||
|
||||
// Mock vscode before any imports that depend on it
|
||||
vitest.mock("vscode", () => ({
|
||||
workspace: {
|
||||
getConfiguration: vitest.fn().mockReturnValue({
|
||||
get: vitest.fn(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock undici Agent
|
||||
const mockAgent = { headersTimeout: 0, bodyTimeout: 0 }
|
||||
vitest.mock("undici", () => ({
|
||||
Agent: vitest.fn().mockImplementation((opts) => {
|
||||
mockAgent.headersTimeout = opts.headersTimeout
|
||||
mockAgent.bodyTimeout = opts.bodyTimeout
|
||||
return mockAgent
|
||||
}),
|
||||
fetch: vitest.fn().mockResolvedValue(new Response("ok")),
|
||||
}))
|
||||
|
||||
import { Agent, fetch as undiciFetch } from "undici"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { createFetchWithUndiciTimeout } from "../undici-fetch"
|
||||
|
||||
describe("createFetchWithUndiciTimeout", () => {
|
||||
let mockGetConfig: any
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
mockGetConfig = vitest.fn()
|
||||
;(vscode.workspace.getConfiguration as any).mockReturnValue({
|
||||
get: mockGetConfig,
|
||||
})
|
||||
})
|
||||
|
||||
it("should create an Undici Agent with headersTimeout and bodyTimeout matching the configured timeout", () => {
|
||||
mockGetConfig.mockReturnValue(600) // 600 seconds
|
||||
|
||||
createFetchWithUndiciTimeout()
|
||||
|
||||
expect(Agent).toHaveBeenCalledWith({
|
||||
headersTimeout: 600000, // 600s in ms
|
||||
bodyTimeout: 600000,
|
||||
})
|
||||
})
|
||||
|
||||
it("should set Agent timeouts to 0 (no timeout) when apiRequestTimeout is disabled", () => {
|
||||
mockGetConfig.mockReturnValue(0) // disabled
|
||||
|
||||
createFetchWithUndiciTimeout()
|
||||
|
||||
// getApiRequestTimeout returns undefined for 0/negative, and undici-fetch maps undefined to 0
|
||||
expect(Agent).toHaveBeenCalledWith({
|
||||
headersTimeout: 0,
|
||||
bodyTimeout: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return a function with the same signature as fetch", () => {
|
||||
mockGetConfig.mockReturnValue(600)
|
||||
|
||||
const fetchFn = createFetchWithUndiciTimeout()
|
||||
|
||||
expect(typeof fetchFn).toBe("function")
|
||||
})
|
||||
|
||||
it("should call undici fetch with the custom dispatcher when invoked", async () => {
|
||||
mockGetConfig.mockReturnValue(600)
|
||||
|
||||
const fetchFn = createFetchWithUndiciTimeout()
|
||||
await fetchFn("https://example.com/api", { method: "POST" })
|
||||
|
||||
expect(undiciFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
dispatcher: mockAgent,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom timeout values", () => {
|
||||
mockGetConfig.mockReturnValue(1200) // 20 minutes
|
||||
|
||||
createFetchWithUndiciTimeout()
|
||||
|
||||
expect(Agent).toHaveBeenCalledWith({
|
||||
headersTimeout: 1200000,
|
||||
bodyTimeout: 1200000,
|
||||
})
|
||||
})
|
||||
})
|
||||
48
src/api/providers/utils/undici-fetch.ts
Normal file
48
src/api/providers/utils/undici-fetch.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Agent, fetch as undiciFetch } from "undici"
|
||||
|
||||
import { getApiRequestTimeout } from "./timeout-config"
|
||||
|
||||
/**
|
||||
* Default Undici headersTimeout is 300 seconds (5 minutes), which can cause
|
||||
* premature HeadersTimeoutError for slow providers (e.g. local models via
|
||||
* LM Studio, Ollama) even when the SDK-level timeout is set higher.
|
||||
*
|
||||
* This utility creates a custom `fetch` function backed by an Undici `Agent`
|
||||
* whose `headersTimeout` and `bodyTimeout` are aligned with the user's
|
||||
* configured `apiRequestTimeout`. This ensures the transport layer won't
|
||||
* abort the connection before the SDK-level timeout fires.
|
||||
*
|
||||
* @see https://github.com/RooCodeInc/Roo-Code/issues/12244
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds a `fetch` function that uses an Undici Agent with headersTimeout
|
||||
* and bodyTimeout set to match the configured API request timeout.
|
||||
*
|
||||
* The returned function has the same signature as the global `fetch` and
|
||||
* can be passed directly to the OpenAI SDK's `fetch` constructor option.
|
||||
*/
|
||||
export function createFetchWithUndiciTimeout(): typeof globalThis.fetch {
|
||||
const timeoutMs = getApiRequestTimeout()
|
||||
|
||||
// When timeout is undefined (user disabled it), use 0 which means
|
||||
// "no timeout" in Undici.
|
||||
const agentTimeout = timeoutMs ?? 0
|
||||
|
||||
const agent = new Agent({
|
||||
headersTimeout: agentTimeout,
|
||||
bodyTimeout: agentTimeout,
|
||||
})
|
||||
|
||||
// Return a fetch wrapper that injects the custom dispatcher.
|
||||
// The OpenAI SDK expects the standard fetch signature.
|
||||
return ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
return undiciFetch(
|
||||
input as any,
|
||||
{
|
||||
...init,
|
||||
dispatcher: agent,
|
||||
} as any,
|
||||
) as unknown as Promise<Response>
|
||||
}) as typeof globalThis.fetch
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue