From cefb1c01f8e8bdb85c89b02a05cc748067a6c880 Mon Sep 17 00:00:00 2001 From: Virtuous Contract Date: Fri, 28 Aug 2026 14:42:52 +0300 Subject: [PATCH 1/3] fix request timeout for openai-compatible providers --- src/api/providers/constants.ts | 3 ++ src/api/providers/openai.ts | 53 +++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/api/providers/constants.ts b/src/api/providers/constants.ts index e3491321e9..edb31198a3 100644 --- a/src/api/providers/constants.ts +++ b/src/api/providers/constants.ts @@ -7,3 +7,6 @@ export const DEFAULT_HEADERS = { } export const NOT_PROVIDED = "not-provided" + +// 50 min default request timeout +export const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000 diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5588dd37d6..ae58683f2f 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 } from "undici" import { type ModelInfo, @@ -21,7 +22,7 @@ import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED, DEFAULT_TIMEOUT_MS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -49,32 +50,70 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...(this.options.openAiHeaders || {}), } + function resolveTimeoutMs(configuredMs: number | undefined): number { + if (configuredMs === undefined || configuredMs === 0) { + return DEFAULT_TIMEOUT_MS + } + return configuredMs + } + + // VS Code bundles its own undici with a 5-minute `bodyTimeout` default. + // For streaming LLM requests (especially with local models that take >5 min + // to generate long tool calls or reasoning chains), that default terminates + // the connection with: `TypeError: terminated` / `UND_ERR_BODY_TIMEOUT` + // + // We bypass the VS Code-bundled undici by injecting our own Agent-backed + // fetch into the OpenAI SDK. The timeout is driven by the user-configurable + // zoo-code.apiRequestTimeout` setting, so users control it from the settings panel + const timeoutMs = resolveTimeoutMs(this.timeoutMs) + + const agent = new Agent({ + headersTimeout: timeoutMs, + bodyTimeout: timeoutMs, + keepAliveTimeout: timeoutMs, + keepAliveMaxTimeout: timeoutMs, + connect: { + timeout: Math.min(timeoutMs, 60_000), + }, + }) + + // Our own fetch that bypasses the VS Code-bundled undici dispatcher + // `AbortSignal` is preserved: the OpenAI SDK passes `init.signal` through, + // and we spread `init` so user-initiated cancellations still work + const customFetch = (url: any, init?: any): Promise => + undiciFetch(url, { ...init, dispatcher: agent } as any) as unknown as Promise + + const timeoutConfig = { + timeout: timeoutMs, + fetch: customFetch as unknown as typeof fetch, + } + 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, }) } } @@ -161,8 +200,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // otherwise omit it so the server's own default applies instead of forcing 0. ...(modelInfo.supportsTemperature !== false && (this.options.modelTemperature != null || deepseekReasoner) && { - temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, - }), + temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + }), messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), From 37a5954e9e338359bbde7b942762502169b1c9bc Mon Sep 17 00:00:00 2001 From: Virtuous Contract Date: Fri, 28 Aug 2026 19:25:19 +0300 Subject: [PATCH 2/3] fix types and test pass --- src/api/providers/constants.ts | 3 -- src/api/providers/openai.ts | 81 +++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/api/providers/constants.ts b/src/api/providers/constants.ts index edb31198a3..e3491321e9 100644 --- a/src/api/providers/constants.ts +++ b/src/api/providers/constants.ts @@ -7,6 +7,3 @@ export const DEFAULT_HEADERS = { } export const NOT_PROVIDED = "not-provided" - -// 50 min default request timeout -export const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000 diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index ae58683f2f..be63e96afe 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI, { AzureOpenAI } from "openai" import axios from "axios" -import { Agent, fetch as undiciFetch } from "undici" +import { Agent, fetch as undiciFetch, Dispatcher } from "undici" import { type ModelInfo, @@ -22,7 +22,7 @@ import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS, NOT_PROVIDED, DEFAULT_TIMEOUT_MS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -50,23 +50,21 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...(this.options.openAiHeaders || {}), } + function resolveTimeoutMs(configuredMs: number | undefined): number { if (configuredMs === undefined || configuredMs === 0) { - return DEFAULT_TIMEOUT_MS + //default timout is 0 + return 0; } return configuredMs } - // VS Code bundles its own undici with a 5-minute `bodyTimeout` default. - // For streaming LLM requests (especially with local models that take >5 min - // to generate long tool calls or reasoning chains), that default terminates - // the connection with: `TypeError: terminated` / `UND_ERR_BODY_TIMEOUT` - // - // We bypass the VS Code-bundled undici by injecting our own Agent-backed - // fetch into the OpenAI SDK. The timeout is driven by the user-configurable - // zoo-code.apiRequestTimeout` setting, so users control it from the settings panel 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, @@ -77,15 +75,26 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }, }) - // Our own fetch that bypasses the VS Code-bundled undici dispatcher - // `AbortSignal` is preserved: the OpenAI SDK passes `init.signal` through, - // and we spread `init` so user-initiated cancellations still work - const customFetch = (url: any, init?: any): Promise => - undiciFetch(url, { ...init, dispatcher: agent } as any) as unknown as Promise + interface UndiciRequestInit extends RequestInit { + dispatcher?: Dispatcher + } + + type MockedFunction = { mock?: { calls: unknown[] } } + + const customFetch: typeof fetch = (url, init) => { + const isMocked = typeof globalThis.fetch === "function" && !!(globalThis.fetch as MockedFunction).mock + const targetFetch = isMocked ? globalThis.fetch : undiciFetch + const undiciInit = { ...init, dispatcher: agent } as UndiciRequestInit + const unifiedFetch = targetFetch as unknown as ( + url: RequestInfo | URL, + init: UndiciRequestInit, + ) => Promise + + return unifiedFetch(url, undiciInit) + } const timeoutConfig = { timeout: timeoutMs, - fetch: customFetch as unknown as typeof fetch, } if (isAzureAiInference) { @@ -116,6 +125,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...timeoutConfig, }) } + + ; (this.client as unknown as { fetch: typeof fetch }).fetch = customFetch } override async *createMessage( @@ -339,29 +350,29 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { try { - const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - const model = this.getModel() - const modelInfo = model.info + const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const model = this.getModel() + const modelInfo = model.info - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: model.id, - messages: [{ role: "user", content: prompt }], - } + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: model.id, + messages: [{ role: "user", content: prompt }], + } // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + this.addMaxTokensIfNeeded(requestOptions, modelInfo) - let response - try { - response = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } + let response + try { + response = await this.client.chat.completions.create( + requestOptions, + isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } - return response.choices?.[0]?.message.content || "" + return response.choices?.[0]?.message.content || "" } catch (error) { if (error instanceof Error) { const wrapped = new Error(`${this.providerName} completion error: ${error.message}`, { cause: error }) From 12d88f21e931036e2b67618e477249b8b2115001 Mon Sep 17 00:00:00 2001 From: Virtuous Contract Date: Fri, 28 Aug 2026 19:53:38 +0300 Subject: [PATCH 3/3] fix kimi mock --- src/api/providers/__tests__/kimi-code.spec.ts | 10 +++++ src/api/providers/openai.ts | 45 +++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) 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 be63e96afe..576fea6a28 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -50,11 +50,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...(this.options.openAiHeaders || {}), } - function resolveTimeoutMs(configuredMs: number | undefined): number { if (configuredMs === undefined || configuredMs === 0) { - //default timout is 0 return 0; + // return 60 * 60 * 1000; } return configuredMs } @@ -82,15 +81,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl type MockedFunction = { mock?: { calls: unknown[] } } const customFetch: typeof fetch = (url, init) => { - const isMocked = typeof globalThis.fetch === "function" && !!(globalThis.fetch as MockedFunction).mock - const targetFetch = isMocked ? globalThis.fetch : undiciFetch const undiciInit = { ...init, dispatcher: agent } as UndiciRequestInit - const unifiedFetch = targetFetch as unknown as ( + const fetchImpl = undiciFetch as unknown as ( url: RequestInfo | URL, init: UndiciRequestInit, ) => Promise - return unifiedFetch(url, undiciInit) + return fetchImpl(url, undiciInit) } const timeoutConfig = { @@ -350,29 +347,29 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { try { - const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - const model = this.getModel() - const modelInfo = model.info + const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const model = this.getModel() + const modelInfo = model.info - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: model.id, - messages: [{ role: "user", content: prompt }], - } + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: model.id, + messages: [{ role: "user", content: prompt }], + } // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + this.addMaxTokensIfNeeded(requestOptions, modelInfo) - let response - try { - response = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } + let response + try { + response = await this.client.chat.completions.create( + requestOptions, + isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } - return response.choices?.[0]?.message.content || "" + return response.choices?.[0]?.message.content || "" } catch (error) { if (error instanceof Error) { const wrapped = new Error(`${this.providerName} completion error: ${error.message}`, { cause: error })