mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add configurable request timeout for OpenAI-compatible providers
- Add openAiRequestTimeout to provider settings schema - Create custom fetch wrapper with configurable undici bodyTimeout - Update OpenAiHandler and BaseOpenAiCompatibleProvider to use custom fetch - Add UI component for timeout configuration in OpenAI-compatible settings - Add translation strings for the new timeout setting - Update tests to expect fetch property in OpenAI constructor This fixes the issue where local models with long prompt processing times would timeout after 5 minutes due to undici default bodyTimeout. Fixes #6570
This commit is contained in:
parent
69685c779d
commit
c60799f1b5
10 changed files with 280 additions and 3 deletions
|
|
@ -144,6 +144,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({
|
|||
openAiStreamingEnabled: z.boolean().optional(),
|
||||
openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
|
||||
openAiHeaders: z.record(z.string(), z.string()).optional(),
|
||||
openAiRequestTimeout: z.number().optional(), // Timeout in milliseconds for OpenAI API requests
|
||||
})
|
||||
|
||||
const ollamaSchema = baseProviderSettingsSchema.extend({
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -685,7 +685,7 @@ importers:
|
|||
version: 12.0.0
|
||||
openai:
|
||||
specifier: ^5.0.0
|
||||
version: 5.5.1(ws@8.18.2)(zod@3.25.61)
|
||||
version: 5.5.1(ws@8.18.3)(zod@3.25.61)
|
||||
os-name:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.0
|
||||
|
|
@ -758,6 +758,9 @@ importers:
|
|||
turndown:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
undici:
|
||||
specifier: '>=5.29.0'
|
||||
version: 6.21.3
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
|
|
@ -17520,9 +17523,9 @@ snapshots:
|
|||
is-inside-container: 1.0.0
|
||||
is-wsl: 3.1.0
|
||||
|
||||
openai@5.5.1(ws@8.18.2)(zod@3.25.61):
|
||||
openai@5.5.1(ws@8.18.3)(zod@3.25.61):
|
||||
optionalDependencies:
|
||||
ws: 8.18.2
|
||||
ws: 8.18.3
|
||||
zod: 3.25.61
|
||||
|
||||
option@0.2.4: {}
|
||||
|
|
|
|||
200
src/api/providers/__tests__/openai-timeout-integration.spec.ts
Normal file
200
src/api/providers/__tests__/openai-timeout-integration.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// npx vitest run api/providers/__tests__/openai-timeout-integration.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { OpenAiHandler } from "../openai"
|
||||
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { ModelInfo } from "@roo-code/types"
|
||||
|
||||
// Mock OpenAI module
|
||||
const mockCreate = vi.fn()
|
||||
const mockOpenAIConstructor = vi.fn()
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
constructor(config: any) {
|
||||
mockOpenAIConstructor(config)
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
AzureOpenAI: class MockAzureOpenAI {
|
||||
constructor(config: any) {
|
||||
mockOpenAIConstructor(config)
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Test provider implementation
|
||||
class TestProvider extends BaseOpenAiCompatibleProvider<"test-model"> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "Test Provider",
|
||||
baseURL: "https://api.test.com",
|
||||
defaultProviderModelId: "test-model",
|
||||
providerModels: {
|
||||
"test-model": {
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
} as ModelInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAI timeout integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("OpenAiHandler", () => {
|
||||
it("should pass timeout configuration to OpenAI client", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
openAiRequestTimeout: 600000, // 10 minutes
|
||||
}
|
||||
|
||||
new OpenAiHandler(options)
|
||||
|
||||
// Check that OpenAI constructor was called with fetch option
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
expect(typeof constructorCall.fetch).toBe("function")
|
||||
})
|
||||
|
||||
it("should work without timeout configuration", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
}
|
||||
|
||||
new OpenAiHandler(options)
|
||||
|
||||
// Should still have fetch function even without explicit timeout
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
})
|
||||
|
||||
it("should handle Azure OpenAI configuration with timeout", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
openAiBaseUrl: "https://test.openai.azure.com",
|
||||
azureApiVersion: "2024-05-01-preview",
|
||||
openAiRequestTimeout: 1200000, // 20 minutes
|
||||
}
|
||||
|
||||
new OpenAiHandler(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
expect(constructorCall.baseURL).toBe("https://test.openai.azure.com")
|
||||
})
|
||||
|
||||
it("should handle Azure AI Inference Service with timeout", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "deepseek-v3",
|
||||
openAiBaseUrl: "https://test.services.ai.azure.com",
|
||||
openAiRequestTimeout: 1800000, // 30 minutes
|
||||
}
|
||||
|
||||
new OpenAiHandler(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
expect(constructorCall.baseURL).toBe("https://test.services.ai.azure.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BaseOpenAiCompatibleProvider", () => {
|
||||
it("should use timeout configuration in derived providers", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiKey: "test-key",
|
||||
openAiRequestTimeout: 900000, // 15 minutes
|
||||
}
|
||||
|
||||
new TestProvider(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
expect(constructorCall.apiKey).toBe("test-key")
|
||||
expect(constructorCall.baseURL).toBe("https://api.test.com")
|
||||
})
|
||||
|
||||
it("should use default timeout when not specified", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiKey: "test-key",
|
||||
}
|
||||
|
||||
new TestProvider(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
})
|
||||
|
||||
it("should handle zero timeout value", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiKey: "test-key",
|
||||
openAiRequestTimeout: 0,
|
||||
}
|
||||
|
||||
new TestProvider(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout behavior", () => {
|
||||
it("should allow very large timeouts for slow local models", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "local-llama",
|
||||
openAiRequestTimeout: 7200000, // 2 hours
|
||||
}
|
||||
|
||||
expect(() => new OpenAiHandler(options)).not.toThrow()
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
})
|
||||
|
||||
it("should handle negative timeout values gracefully", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiKey: "test-key",
|
||||
openAiRequestTimeout: -5000,
|
||||
}
|
||||
|
||||
expect(() => new TestProvider(options)).not.toThrow()
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalled()
|
||||
const constructorCall = mockOpenAIConstructor.mock.calls[0][0]
|
||||
expect(constructorCall).toHaveProperty("fetch")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -107,6 +107,7 @@ describe("OpenAiHandler", () => {
|
|||
"X-Title": "Roo Code",
|
||||
"User-Agent": `RooCode/${Package.version}`,
|
||||
},
|
||||
fetch: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format"
|
|||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import { createCustomFetch, DEFAULT_OPENAI_REQUEST_TIMEOUT } from "./utils/custom-fetch"
|
||||
|
||||
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
|
||||
providerName: string
|
||||
|
|
@ -55,10 +56,14 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
throw new Error("API key is required")
|
||||
}
|
||||
|
||||
// Create custom fetch with timeout if configured
|
||||
const customFetch = createCustomFetch(this.options.openAiRequestTimeout || DEFAULT_OPENAI_REQUEST_TIMEOUT)
|
||||
|
||||
this.client = new OpenAI({
|
||||
baseURL,
|
||||
apiKey: this.options.apiKey,
|
||||
defaultHeaders: DEFAULT_HEADERS,
|
||||
fetch: customFetch,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { getModelParams } from "../transform/model-params"
|
|||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { createCustomFetch, DEFAULT_OPENAI_REQUEST_TIMEOUT } from "./utils/custom-fetch"
|
||||
|
||||
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
|
||||
// `OpenAINativeHandler` can subclass from this, since it's obviously
|
||||
|
|
@ -46,6 +47,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
...(this.options.openAiHeaders || {}),
|
||||
}
|
||||
|
||||
// Create custom fetch with timeout if configured
|
||||
const customFetch = createCustomFetch(this.options.openAiRequestTimeout || DEFAULT_OPENAI_REQUEST_TIMEOUT)
|
||||
|
||||
if (isAzureAiInference) {
|
||||
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
|
||||
this.client = new OpenAI({
|
||||
|
|
@ -53,6 +57,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
apiKey,
|
||||
defaultHeaders: headers,
|
||||
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
|
||||
fetch: customFetch,
|
||||
})
|
||||
} else if (isAzureOpenAi) {
|
||||
// Azure API shape slightly differs from the core API shape:
|
||||
|
|
@ -62,12 +67,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
apiKey,
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: headers,
|
||||
fetch: customFetch,
|
||||
})
|
||||
} else {
|
||||
this.client = new OpenAI({
|
||||
baseURL,
|
||||
apiKey,
|
||||
defaultHeaders: headers,
|
||||
fetch: customFetch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/api/providers/utils/custom-fetch.ts
Normal file
39
src/api/providers/utils/custom-fetch.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { fetch as undiciFetch, type RequestInit as UndiciRequestInit } from "undici"
|
||||
|
||||
/**
|
||||
* Creates a custom fetch function with configurable timeout for OpenAI providers.
|
||||
* This addresses the issue where undici's default bodyTimeout of 5 minutes
|
||||
* causes problems with slow local models during prompt processing.
|
||||
*
|
||||
* @param timeout - Timeout in milliseconds. If not provided, uses default behavior.
|
||||
* @returns A fetch function compatible with the Fetch API
|
||||
*/
|
||||
export function createCustomFetch(timeout?: number): typeof fetch {
|
||||
if (!timeout) {
|
||||
// If no timeout is specified, return the standard fetch
|
||||
return fetch
|
||||
}
|
||||
|
||||
return function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
// Convert standard fetch parameters to Undici format with extended timeout options
|
||||
const undiciOptions: UndiciRequestInit = {
|
||||
...init,
|
||||
// bodyTimeout controls how long to wait for the response body
|
||||
// This is what causes the 5-minute timeout issue with slow models
|
||||
bodyTimeout: timeout,
|
||||
// headersTimeout controls how long to wait for response headers
|
||||
headersTimeout: timeout,
|
||||
} as UndiciRequestInit
|
||||
|
||||
// Use undici's fetch with extended timeout options
|
||||
// Type assertions handle compatibility between fetch and undici types
|
||||
return undiciFetch(input as any, undiciOptions) as any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default timeout for OpenAI API requests (30 minutes in milliseconds).
|
||||
* This provides a reasonable default for slow local models while still
|
||||
* preventing indefinite hangs.
|
||||
*/
|
||||
export const DEFAULT_OPENAI_REQUEST_TIMEOUT = 30 * 60 * 1000 // 30 minutes
|
||||
|
|
@ -476,6 +476,7 @@
|
|||
"tmp": "^0.2.3",
|
||||
"tree-sitter-wasms": "^0.1.12",
|
||||
"turndown": "^7.2.0",
|
||||
"undici": "^5.29.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vscode-material-icons": "^0.1.1",
|
||||
"web-tree-sitter": "^0.25.6",
|
||||
|
|
|
|||
|
|
@ -177,6 +177,24 @@ export const OpenAICompatible = ({
|
|||
{t("settings:includeMaxOutputTokensDescription")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiRequestTimeout?.toString() || ""}
|
||||
type="number"
|
||||
min="0"
|
||||
onInput={handleInputChange("openAiRequestTimeout", (e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
const parsed = parseInt(value)
|
||||
return isNaN(parsed) || parsed === 0 ? undefined : parsed
|
||||
})}
|
||||
placeholder="1800000"
|
||||
className="w-full">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.requestTimeout")}</label>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
{t("settings:providers.requestTimeoutDescription")}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={apiConfiguration?.openAiUseAzure ?? false}
|
||||
onChange={handleInputChange("openAiUseAzure", noTransform)}>
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@
|
|||
"searchProviderPlaceholder": "Search providers",
|
||||
"noProviderMatchFound": "No providers found",
|
||||
"noMatchFound": "No matching profiles found",
|
||||
"requestTimeout": "Request Timeout (ms)",
|
||||
"requestTimeoutDescription": "Timeout in milliseconds for API requests. Useful for slow local models that take time to process prompts. Default: 1800000 (30 minutes)",
|
||||
"vscodeLmDescription": " The VS Code Language Model API allows you to run models provided by other VS Code extensions (including but not limited to GitHub Copilot). The easiest way to get started is to install the Copilot and Copilot Chat extensions from the VS Code Marketplace.",
|
||||
"awsCustomArnUse": "Enter a valid Amazon Bedrock ARN for the model you want to use. Format examples:",
|
||||
"awsCustomArnDesc": "Make sure the region in the ARN matches your selected AWS Region above.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue