diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 2aee4ea052..d1d337439a 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/groq.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 88fa2d313c..d6f03c2a15 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -5,9 +5,6 @@ import { LiteLLMHandler } from "../lite-llm" import { ApiHandlerOptions } from "../../../shared/api" import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" -// Mock vscode first to avoid import errors -vi.mock("vscode", () => ({})) - // Mock OpenAI vi.mock("openai", () => { const mockStream = { diff --git a/src/api/providers/__tests__/lm-studio-timeout.spec.ts b/src/api/providers/__tests__/lm-studio-timeout.spec.ts index 659fcaaf67..734dcbfefe 100644 --- a/src/api/providers/__tests__/lm-studio-timeout.spec.ts +++ b/src/api/providers/__tests__/lm-studio-timeout.spec.ts @@ -72,7 +72,7 @@ describe("LmStudioHandler timeout configuration", () => { ) }) - it("should handle zero timeout (no timeout)", () => { + it("should handle zero timeout", () => { ;(getApiRequestTimeout as any).mockReturnValue(0) const options: ApiHandlerOptions = { @@ -84,7 +84,7 @@ describe("LmStudioHandler timeout configuration", () => { expect(mockOpenAIConstructor).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 0, // No timeout + timeout: 0, }), ) }) diff --git a/src/api/providers/__tests__/ollama-timeout.spec.ts b/src/api/providers/__tests__/ollama-timeout.spec.ts index db78f206c0..7e3f9c66a0 100644 --- a/src/api/providers/__tests__/ollama-timeout.spec.ts +++ b/src/api/providers/__tests__/ollama-timeout.spec.ts @@ -84,7 +84,7 @@ describe("OllamaHandler timeout configuration", () => { expect(mockOpenAIConstructor).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 0, // No timeout + timeout: 0, }), ) }) diff --git a/src/api/providers/__tests__/openai-timeout.spec.ts b/src/api/providers/__tests__/openai-timeout.spec.ts index 2a09fd94ff..5f0a9af5f2 100644 --- a/src/api/providers/__tests__/openai-timeout.spec.ts +++ b/src/api/providers/__tests__/openai-timeout.spec.ts @@ -137,7 +137,7 @@ describe("OpenAiHandler timeout configuration", () => { expect(mockOpenAIConstructor).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 0, // No timeout + timeout: 0, }), ) }) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ae36fc1399..a4e62e6be8 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/openrouter.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" @@ -65,6 +62,7 @@ describe("OpenRouterHandler", () => { "X-Title": "Roo Code", "User-Agent": `RooCode/${Package.version}`, }, + timeout: 600000, }) }) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 4d5037ed9e..40ff07f8f4 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -62,6 +62,7 @@ describe("RequestyHandler", () => { "X-Title": "Roo Code", "User-Agent": `RooCode/${Package.version}`, }, + timeout: 600000, }) }) @@ -77,6 +78,7 @@ describe("RequestyHandler", () => { "X-Title": "Roo Code", "User-Agent": `RooCode/${Package.version}`, }, + timeout: 600000, }) }) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index d8cae8bf80..b8a4a859ab 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/sambanova.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index a3e7c9e7d5..f964b63a04 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/vercel-ai-gateway.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" @@ -102,6 +99,7 @@ describe("VercelAiGatewayHandler", () => { "X-Title": "Roo Code", "User-Agent": expect.stringContaining("RooCode/"), }), + timeout: 600000, }) }) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index d147e79ba8..479077d432 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/vertex.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import { Anthropic } from "@anthropic-ai/sdk" import { ApiStreamChunk } from "../../transform/stream" diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 7928a4298d..354725e3de 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/zai.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index cb48492b60..34299239d1 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -18,6 +18,7 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" +import { getApiRequestTimeout } from "./utils/timeout-config" export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions @@ -30,9 +31,12 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const apiKeyFieldName = this.options.anthropicBaseUrl && this.options.anthropicUseAuthToken ? "authToken" : "apiKey" + const timeout = getApiRequestTimeout() + this.client = new Anthropic({ baseURL: this.options.anthropicBaseUrl || undefined, [apiKeyFieldName]: this.options.apiKey, + timeout, }) } diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index fb6c5d0377..f7037f5792 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -9,6 +9,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { DEFAULT_HEADERS } from "./constants" +import { getApiRequestTimeout } from "./utils/timeout-config" import { BaseProvider } from "./base-provider" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -56,10 +57,13 @@ export abstract class BaseOpenAiCompatibleProvider throw new Error("API key is required") } + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL, apiKey: this.options.apiKey, defaultHeaders: DEFAULT_HEADERS, + timeout, }) } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index c6a0b35df4..9cd12e9894 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -33,6 +33,7 @@ import { convertToBedrockConverseMessages as sharedConverter } from "../transfor import { getModelParams } from "../transform/model-params" import { shouldUseReasoningBudget } from "../../shared/api" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { getApiRequestTimeout } from "./utils/timeout-config" /************************************************************************************ * @@ -401,17 +402,17 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }), } - // Create AbortController with 10 minute timeout + // Create AbortController with configured timeout const controller = new AbortController() let timeoutId: NodeJS.Timeout | undefined + const timeoutMs = getApiRequestTimeout() try { - timeoutId = setTimeout( - () => { + if (timeoutMs !== 0) { + timeoutId = setTimeout(() => { controller.abort() - }, - 10 * 60 * 1000, - ) + }, timeoutMs) + } const command = new ConverseStreamCommand(payload) const response = await this.client.send(command, { @@ -670,8 +671,18 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH inferenceConfig, } + const controller = new AbortController() + let timeoutId: NodeJS.Timeout | undefined + const timeoutMs = getApiRequestTimeout() + + if (timeoutMs !== 0) { + timeoutId = setTimeout(() => { + controller.abort() + }, timeoutMs) + } + const command = new ConverseCommand(payload) - const response = await this.client.send(command) + const response = await this.client.send(command, { abortSignal: controller.signal }) if ( response?.output?.message?.content && @@ -680,6 +691,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH response.output.message.content[0].text.trim().length > 0 ) { try { + if (timeoutId) clearTimeout(timeoutId) return response.output.message.content[0].text } catch (parseError) { logger.error("Failed to parse Bedrock response", { @@ -688,6 +700,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH }) } } + if (timeoutId) clearTimeout(timeoutId) return "" } catch (error) { // Use the extracted error handling method for all errors diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index a0421844e8..2f8466f23f 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -12,6 +12,7 @@ import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from ". import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" import { t } from "../../i18n" +import { getApiRequestTimeout } from "./utils/timeout-config" const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1" const CEREBRAS_DEFAULT_TEMPERATURE = 0 @@ -146,128 +147,143 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan } try { - const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { - method: "POST", - headers: { - ...DEFAULT_HEADERS, - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - const errorText = await response.text() - - let errorMessage = "Unknown error" - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || JSON.stringify(errorJson, null, 2) - } catch { - errorMessage = errorText || `HTTP ${response.status}` + const controller = new AbortController() + let timeout = getApiRequestTimeout() + let timer: NodeJS.Timeout | undefined + try { + if (timeout !== 0) { + timer = setTimeout(() => controller.abort(), timeout) } + const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + ...DEFAULT_HEADERS, + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + + let errorMessage = "Unknown error" + try { + const errorJson = JSON.parse(errorText) + errorMessage = + errorJson.error?.message || errorJson.message || JSON.stringify(errorJson, null, 2) + } catch { + errorMessage = errorText || `HTTP ${response.status}` + } - // Provide more actionable error messages - if (response.status === 401) { - throw new Error(t("common:errors.cerebras.authenticationFailed")) - } else if (response.status === 403) { - throw new Error(t("common:errors.cerebras.accessForbidden")) - } else if (response.status === 429) { - throw new Error(t("common:errors.cerebras.rateLimitExceeded")) - } else if (response.status >= 500) { - throw new Error(t("common:errors.cerebras.serverError", { status: response.status })) - } else { - throw new Error( - t("common:errors.cerebras.genericError", { status: response.status, message: errorMessage }), - ) + // Provide more actionable error messages + if (response.status === 401) { + throw new Error(t("common:errors.cerebras.authenticationFailed")) + } else if (response.status === 403) { + throw new Error(t("common:errors.cerebras.accessForbidden")) + } else if (response.status === 429) { + throw new Error(t("common:errors.cerebras.rateLimitExceeded")) + } else if (response.status >= 500) { + throw new Error(t("common:errors.cerebras.serverError", { status: response.status })) + } else { + throw new Error( + t("common:errors.cerebras.genericError", { + status: response.status, + message: errorMessage, + }), + ) + } } - } - if (!response.body) { - throw new Error(t("common:errors.cerebras.noResponseBody")) - } + if (!response.body) { + throw new Error(t("common:errors.cerebras.noResponseBody")) + } - // Initialize XmlMatcher to parse ... tags - const matcher = new XmlMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let inputTokens = 0 - let outputTokens = 0 + // Initialize XmlMatcher to parse ... tags + const matcher = new XmlMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + let inputTokens = 0 + let outputTokens = 0 - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" // Keep the last incomplete line in the buffer - - for (const line of lines) { - if (line.trim() === "") continue - - try { - if (line.startsWith("data: ")) { - const jsonStr = line.slice(6).trim() - if (jsonStr === "[DONE]") { - continue - } + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" // Keep the last incomplete line in the buffer + + for (const line of lines) { + if (line.trim() === "") continue + + try { + if (line.startsWith("data: ")) { + const jsonStr = line.slice(6).trim() + if (jsonStr === "[DONE]") { + continue + } - const parsed = JSON.parse(jsonStr) + const parsed = JSON.parse(jsonStr) - // Handle text content - parse for thinking tokens - if (parsed.choices?.[0]?.delta?.content) { - const content = parsed.choices[0].delta.content + // Handle text content - parse for thinking tokens + if (parsed.choices?.[0]?.delta?.content) { + const content = parsed.choices[0].delta.content - // Use XmlMatcher to parse ... tags - for (const chunk of matcher.update(content)) { - yield chunk + // Use XmlMatcher to parse ... tags + for (const chunk of matcher.update(content)) { + yield chunk + } } - } - // Handle usage information if available - if (parsed.usage) { - inputTokens = parsed.usage.prompt_tokens || 0 - outputTokens = parsed.usage.completion_tokens || 0 + // Handle usage information if available + if (parsed.usage) { + inputTokens = parsed.usage.prompt_tokens || 0 + outputTokens = parsed.usage.completion_tokens || 0 + } } + } catch (error) { + // Silently ignore malformed streaming data lines } - } catch (error) { - // Silently ignore malformed streaming data lines } } + } finally { + reader.releaseLock() } - } finally { - reader.releaseLock() - } - // Process any remaining content in the matcher - for (const chunk of matcher.final()) { - yield chunk - } + // Process any remaining content in the matcher + for (const chunk of matcher.final()) { + yield chunk + } - // Provide token usage estimate if not available from API - if (inputTokens === 0 || outputTokens === 0) { - const inputText = systemPrompt + cerebrasMessages.map((m) => m.content).join("") - inputTokens = inputTokens || Math.ceil(inputText.length / 4) // Rough estimate: 4 chars per token - outputTokens = outputTokens || Math.ceil((max_tokens || 1000) / 10) // Rough estimate - } + // Provide token usage estimate if not available from API + if (inputTokens === 0 || outputTokens === 0) { + const inputText = systemPrompt + cerebrasMessages.map((m) => m.content).join("") + inputTokens = inputTokens || Math.ceil(inputText.length / 4) // Rough estimate: 4 chars per token + outputTokens = outputTokens || Math.ceil((max_tokens || 1000) / 10) // Rough estimate + } - // Store usage for cost calculation - this.lastUsage = { inputTokens, outputTokens } + // Store usage for cost calculation + this.lastUsage = { inputTokens, outputTokens } - yield { - type: "usage", - inputTokens, - outputTokens, + yield { + type: "usage", + inputTokens, + outputTokens, + } + } finally { + if (timer) clearTimeout(timer) } } catch (error) { if (error instanceof Error) { diff --git a/src/api/providers/huggingface.ts b/src/api/providers/huggingface.ts index 7b62046b99..23e0eb77d0 100644 --- a/src/api/providers/huggingface.ts +++ b/src/api/providers/huggingface.ts @@ -6,6 +6,7 @@ import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { DEFAULT_HEADERS } from "./constants" +import { getApiRequestTimeout } from "./utils/timeout-config" import { BaseProvider } from "./base-provider" import { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -24,10 +25,13 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion throw new Error("Hugging Face API key is required") } + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: "https://router.huggingface.co/v1", apiKey: this.options.huggingFaceApiKey, defaultHeaders: DEFAULT_HEADERS, + timeout, }) // Try to get cached models first diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 6c58a96ae1..91df2ce38b 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -29,10 +29,12 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan // LM Studio uses "noop" as a placeholder API key const apiKey = "noop" + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1", - apiKey: apiKey, - timeout: getApiRequestTimeout(), + apiKey: "noop", + timeout, }) } diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index ab9df116aa..e11ef7f153 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -36,10 +36,12 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl headers["Authorization"] = `Bearer ${this.options.ollamaApiKey}` } + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1", apiKey: apiKey, - timeout: getApiRequestTimeout(), + timeout, defaultHeaders: headers, }) } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 8a205a06b4..23b4fe93f7 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -22,6 +22,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" +import { getApiRequestTimeout } from "./utils/timeout-config" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" export type OpenAiNativeModel = ReturnType @@ -62,7 +63,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.options.enableGpt5ReasoningSummary = true } const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey }) + { + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey, timeout }) + } } private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined { @@ -467,125 +471,146 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const url = `${baseUrl}/v1/responses` try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - Accept: "text/event-stream", - }, - body: JSON.stringify(requestBody), - }) + const controller = new AbortController() + let timeoutVal = getApiRequestTimeout() + let timer: NodeJS.Timeout | undefined + try { + if (timeoutVal !== 0) { + timer = setTimeout(() => controller.abort(), timeoutVal) + } + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + Accept: "text/event-stream", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + + let errorMessage = `GPT-5 API request failed (${response.status})` + let errorDetails = "" + + // Try to parse error as JSON for better error messages + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorDetails = errorJson.error.message + } else if (errorJson.message) { + errorDetails = errorJson.message + } else { + errorDetails = errorText + } + } catch { + // If not JSON, use the raw text + errorDetails = errorText + } - if (!response.ok) { - const errorText = await response.text() + // Check if this is a 400 error about previous_response_id not found + const isPreviousResponseError = + errorDetails.includes("Previous response") || errorDetails.includes("not found") - let errorMessage = `GPT-5 API request failed (${response.status})` - let errorDetails = "" + if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) { + // Log the error and retry without the previous_response_id - // Try to parse error as JSON for better error messages - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorDetails = errorJson.error.message - } else if (errorJson.message) { - errorDetails = errorJson.message - } else { - errorDetails = errorText - } - } catch { - // If not JSON, use the raw text - errorDetails = errorText - } + // Clear the stored lastResponseId to prevent using it again + this.lastResponseId = undefined + // Resolve the promise once to unblock any waiting requests + this.resolveResponseId(undefined) - // Check if this is a 400 error about previous_response_id not found - const isPreviousResponseError = - errorDetails.includes("Previous response") || errorDetails.includes("not found") + // Re-prepare the full conversation without previous_response_id + let retryRequestBody = { ...requestBody } + delete retryRequestBody.previous_response_id - if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) { - // Log the error and retry without the previous_response_id + // If we have the original messages, re-prepare the full conversation + if (systemPrompt && messages) { + const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined) + retryRequestBody.input = formattedInput + } - // Clear the stored lastResponseId to prevent using it again - this.lastResponseId = undefined - // Resolve the promise once to unblock any waiting requests - this.resolveResponseId(undefined) + // Retry the request with full conversation context + const retryController = new AbortController() + let retryTimer: NodeJS.Timeout | undefined + try { + if (timeoutVal !== 0) { + retryTimer = setTimeout(() => retryController.abort(), timeoutVal) + } + const retryResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + Accept: "text/event-stream", + }, + body: JSON.stringify(retryRequestBody), + signal: retryController.signal, + }) + + if (!retryResponse.ok) { + // If retry also fails, throw the original error + throw new Error(`Responses API retry failed (${retryResponse.status})`) + } - // Re-prepare the full conversation without previous_response_id - let retryRequestBody = { ...requestBody } - delete retryRequestBody.previous_response_id + if (!retryResponse.body) { + throw new Error("Responses API error: No response body from retry request") + } - // If we have the original messages, re-prepare the full conversation - if (systemPrompt && messages) { - const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined) - retryRequestBody.input = formattedInput + // Handle the successful retry response + yield* this.handleStreamResponse(retryResponse.body, model) + return + } finally { + if (retryTimer) clearTimeout(retryTimer) + } } - // Retry the request with full conversation context - const retryResponse = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - Accept: "text/event-stream", - }, - body: JSON.stringify(retryRequestBody), - }) - - if (!retryResponse.ok) { - // If retry also fails, throw the original error - throw new Error(`Responses API retry failed (${retryResponse.status})`) + // Provide user-friendly error messages based on status code + switch (response.status) { + case 400: + errorMessage = "Invalid request to Responses API. Please check your input parameters." + break + case 401: + errorMessage = "Authentication failed. Please check your OpenAI API key." + break + case 403: + errorMessage = "Access denied. Your API key may not have access to this endpoint." + break + case 404: + errorMessage = + "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." + break + case 429: + errorMessage = "Rate limit exceeded. Please try again later." + break + case 500: + case 502: + case 503: + errorMessage = "OpenAI service error. Please try again later." + break + default: + errorMessage = `Responses API error (${response.status})` } - if (!retryResponse.body) { - throw new Error("Responses API error: No response body from retry request") + // Append details if available + if (errorDetails) { + errorMessage += ` - ${errorDetails}` } - // Handle the successful retry response - yield* this.handleStreamResponse(retryResponse.body, model) - return - } - - // Provide user-friendly error messages based on status code - switch (response.status) { - case 400: - errorMessage = "Invalid request to Responses API. Please check your input parameters." - break - case 401: - errorMessage = "Authentication failed. Please check your OpenAI API key." - break - case 403: - errorMessage = "Access denied. Your API key may not have access to this endpoint." - break - case 404: - errorMessage = - "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." - break - case 429: - errorMessage = "Rate limit exceeded. Please try again later." - break - case 500: - case 502: - case 503: - errorMessage = "OpenAI service error. Please try again later." - break - default: - errorMessage = `Responses API error (${response.status})` + throw new Error(errorMessage) } - // Append details if available - if (errorDetails) { - errorMessage += ` - ${errorDetails}` + if (!response.body) { + throw new Error("Responses API error: No response body") } - throw new Error(errorMessage) + // Handle streaming response + yield* this.handleStreamResponse(response.body, model) + } finally { + if (timer) clearTimeout(timer) } - - if (!response.body) { - throw new Error("Responses API error: No response body") - } - - // Handle streaming response - yield* this.handleStreamResponse(response.body, model) } catch (error) { if (error instanceof Error) { // Re-throw with the original error message if it's already formatted diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 580b173311..dd82899bd9 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -24,6 +24,7 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" +import { getApiRequestTimeout } from "./utils/timeout-config" import type { SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -95,7 +96,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" const apiKey = this.options.openRouterApiKey ?? "not-provided" - this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS, timeout }) } override async *createMessage( diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index d930d9dfc7..4a152c7c3f 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -13,6 +13,7 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler } from "../index" +import { getApiRequestTimeout } from "./utils/timeout-config" const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token` @@ -64,9 +65,11 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan if (!this.client) { // Create the client instance with dummy key initially // The API key will be updated dynamically via ensureAuthenticated + const timeout = getApiRequestTimeout() this.client = new OpenAI({ apiKey: "dummy-key-will-be-replaced", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + timeout, }) } return this.client diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 16aefae528..b38d62c00b 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -14,6 +14,7 @@ import { AnthropicReasoningParams } from "../transform/reasoning" import { DEFAULT_HEADERS } from "./constants" import { getModels } from "./fetchers/modelCache" import { BaseProvider } from "./base-provider" +import { getApiRequestTimeout } from "./utils/timeout-config" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { toRequestyServiceUrl } from "../../shared/utils/requesty" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -53,10 +54,13 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan const apiKey = this.options.requestyApiKey ?? "not-provided" + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: this.baseURL, apiKey: apiKey, defaultHeaders: DEFAULT_HEADERS, + timeout, }) } diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 25e9a11e1b..52a6171db9 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -8,6 +8,7 @@ import { BaseProvider } from "./base-provider" import { getModels } from "./fetchers/modelCache" import { DEFAULT_HEADERS } from "./constants" +import { getApiRequestTimeout } from "./utils/timeout-config" type RouterProviderOptions = { name: RouterName @@ -45,6 +46,8 @@ export abstract class RouterProvider extends BaseProvider { this.defaultModelId = defaultModelId this.defaultModelInfo = defaultModelInfo + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL, apiKey, @@ -52,6 +55,7 @@ export abstract class RouterProvider extends BaseProvider { ...DEFAULT_HEADERS, ...(options.openAiHeaders || {}), }, + timeout, }) } diff --git a/src/api/providers/utils/__tests__/timeout-config.spec.ts b/src/api/providers/utils/__tests__/timeout-config.spec.ts index f74e14afad..8698783cb3 100644 --- a/src/api/providers/utils/__tests__/timeout-config.spec.ts +++ b/src/api/providers/utils/__tests__/timeout-config.spec.ts @@ -41,20 +41,20 @@ describe("getApiRequestTimeout", () => { expect(timeout).toBe(1200000) // 1200 seconds in milliseconds }) - it("should handle zero timeout (no timeout)", () => { + it("should handle zero timeout (no timeout) by returning safe maximum", () => { mockGetConfig.mockReturnValue(0) const timeout = getApiRequestTimeout() - expect(timeout).toBe(0) // No timeout + expect(timeout).toBe(2147483647) // Safe maximum for setTimeout }) - it("should handle negative values by clamping to 0", () => { + it("should handle negative values by clamping to safe maximum", () => { mockGetConfig.mockReturnValue(-100) const timeout = getApiRequestTimeout() - expect(timeout).toBe(0) // Negative values should be clamped to 0 + expect(timeout).toBe(2147483647) // Negative values should be clamped to 0, then converted to safe maximum }) it("should handle null by using default", () => { diff --git a/src/api/providers/utils/timeout-config.ts b/src/api/providers/utils/timeout-config.ts index 94ddf4afc1..a1d62e3241 100644 --- a/src/api/providers/utils/timeout-config.ts +++ b/src/api/providers/utils/timeout-config.ts @@ -1,9 +1,17 @@ import * as vscode from "vscode" +// Use 2147483647 (2^31 - 1) as the maximum timeout value for setTimeout +// JavaScript's setTimeout has a maximum delay limit of 2147483647ms (32-bit signed integer max) +// Values larger than this may be clamped to 1ms or cause unexpected behavior +// 2147483647 is the safe maximum value that won't cause issues +const MAX_TIMEOUT_MS = 2147483647 + +const DEFAULT_TIMEOUT_MS = 600 * 1000 + /** * Gets the API request timeout from VSCode configuration with validation. * - * @returns The timeout in milliseconds. Returns 0 for no timeout. + * @returns The timeout in milliseconds. Returns 2147483647 (max value for 32-bit signed integer) for no timeout (when config is 0). */ export function getApiRequestTimeout(): number { // Get timeout with validation to ensure it's a valid non-negative number @@ -11,11 +19,19 @@ export function getApiRequestTimeout(): number { // Validate that it's actually a number and not NaN if (typeof configTimeout !== "number" || isNaN(configTimeout)) { - return 600 * 1000 // Default to 600 seconds + return DEFAULT_TIMEOUT_MS } // Allow 0 (no timeout) but clamp negative values to 0 const timeoutSeconds = configTimeout < 0 ? 0 : configTimeout - return timeoutSeconds * 1000 // Convert to milliseconds + // Convert to milliseconds + const timeoutMs = timeoutSeconds * 1000 + + // Handle the special case where 0 means "no timeout" + if (timeoutMs === 0) { + return MAX_TIMEOUT_MS + } + + return timeoutMs } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 7eb6e9866d..5449267558 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -11,6 +11,7 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" +import { getApiRequestTimeout } from "./utils/timeout-config" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -27,10 +28,13 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const apiKey = this.options.xaiApiKey ?? "not-provided" + const timeout = getApiRequestTimeout() + this.client = new OpenAI({ baseURL: "https://api.x.ai/v1", apiKey: apiKey, defaultHeaders: DEFAULT_HEADERS, + timeout, }) }