diff --git a/apps/vscode-e2e/package.json b/apps/vscode-e2e/package.json index 4a2bed8a87..55983817e0 100644 --- a/apps/vscode-e2e/package.json +++ b/apps/vscode-e2e/package.json @@ -18,11 +18,11 @@ "clean": "rimraf out .turbo" }, "devDependencies": { + "@copilotkit/aimock": "1.35.0", "@playwright/test": "1.62.1", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@roo-code/types": "workspace:^", - "@copilotkit/aimock": "1.35.0", "@types/mocha": "10.0.10", "@types/node": "22.20.1", "@types/vscode": "1.100.0", @@ -30,6 +30,7 @@ "dotenv-cli": "11.0.0", "glob": "11.1.0", "mocha": "11.2.2", - "rimraf": "6.0.1" + "rimraf": "6.0.1", + "undici": "^6.21.3" } } diff --git a/apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts b/apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts index d5be2eba2f..ab0f4d8914 100644 --- a/apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts +++ b/apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts @@ -3,12 +3,20 @@ import * as assert from "assert" import * as fs from "fs/promises" import * as path from "path" import * as vscode from "vscode" - +import * as undici from "undici" import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "../test-utils" import { sleep, waitFor, waitUntilAborted } from "../utils" +interface UndiciModule { + fetch: typeof fetch +} + +interface UndiciRequestInit extends RequestInit { + dispatcher?: unknown +} + const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY type DeepSeekModelId = "deepseek-v4-flash" | "deepseek-v4-pro" @@ -71,10 +79,11 @@ function getRequestBody(init?: RequestInit): } function installDeepSeekRequestCapture(capture: CapturedDeepSeekRequest[], baseUrl: string): () => void { - const originalFetch = globalThis.fetch + const undiciModule = undici as unknown as UndiciModule + const originalFetch = undiciModule.fetch const targetOrigin = new URL(baseUrl).origin - globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise { + undiciModule.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise { const url = getRequestUrl(input) if (isUrlWithOrigin(url, targetOrigin) && isChatCompletionsUrl(url)) { @@ -106,11 +115,12 @@ function installDeepSeekRequestCapture(capture: CapturedDeepSeekRequest[], baseU capture.push(request) } - return originalFetch.call(globalThis, input, init as RequestInit) - } as typeof globalThis.fetch + // Передаём init как есть (включая dispatcher из OpenAiHandler) оригинальному undici.fetch + return originalFetch.call(undiciModule, input, init as UndiciRequestInit) + } return () => { - globalThis.fetch = originalFetch + undiciModule.fetch = originalFetch } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47f526185f..705c27eaea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + undici: + specifier: 6.28.0 + version: 6.28.0 packages/build: devDependencies: diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index fe229910b8..edaa5d5ba7 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -10,6 +10,16 @@ const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.ho mockGetModels: vi.fn(), })) +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetch: vi.fn().mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => { + return globalThis.fetch(url, init) + }), + } +}) + vi.mock("../../../integrations/kimi-code/oauth", () => ({ kimiCodeOAuthManager: { getAccessToken: mockGetAccessToken, diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 04b12f233d..9ff54020ba 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI, { AzureOpenAI } from "openai" import axios from "axios" +import { Agent, fetch as undiciFetch, Dispatcher } from "undici" import { type ModelInfo, @@ -52,34 +53,79 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...(this.options.openAiHeaders || {}), } + function resolveTimeoutMs(configuredMs: number | undefined): number { + if (configuredMs === undefined || configuredMs === 0) { + return 0 + // return 60 * 60 * 1000; + } + return configuredMs + } + + const timeoutMs = resolveTimeoutMs(this.timeoutMs) + + // VS Code bundles its own undici with a 5-minute `bodyTimeout` default. + // For streaming LLM requests, that default terminates the connection. + // We bypass the VS Code-bundled undici by injecting our own Agent-backed + // fetch into the OpenAI SDK. + const agent = new Agent({ + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + keepAliveTimeout: timeoutMs, + keepAliveMaxTimeout: timeoutMs, + connect: { + timeout: Math.min(timeoutMs, 60_000), + }, + }) + + interface UndiciRequestInit extends RequestInit { + dispatcher?: Dispatcher + } + + const customFetch: typeof fetch = (url, init) => { + const undiciInit = { ...init, dispatcher: agent } as UndiciRequestInit + // return globalThis.fetch(url, undiciInit as RequestInit) + const fetchImpl = undiciFetch as unknown as ( + url: RequestInfo | URL, + init: UndiciRequestInit, + ) => Promise + + return fetchImpl(url, undiciInit) + } + + const timeoutConfig = { + timeout: timeoutMs, + } + if (isAzureAiInference) { - // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: headers, defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, - timeout: this.timeoutMs, + ...timeoutConfig, }) } else if (isAzureOpenAi) { // Azure API shape slightly differs from the core API shape: // https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai + const azureBaseURL = `${baseURL.replace(/\/openai\/?$/i, "").replace(/\/$/, "")}/openai` this.client = new AzureOpenAI({ baseURL: azureBaseURL, apiKey, apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, defaultHeaders: headers, - timeout: this.timeoutMs, + ...timeoutConfig, }) } else { this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: headers, - timeout: this.timeoutMs, + ...timeoutConfig, }) } + + ;(this.client as unknown as { fetch: typeof fetch }).fetch = customFetch } override async *createMessage(