diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 8a086b48db..6ab9b7fbbc 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -426,6 +426,26 @@ describe("OpenAiNativeHandler", () => { } }).rejects.toThrow("OpenAI service error") }) + + it("uses a versioned custom base URL once and explains 404 model/endpoint failures", async () => { + const mockFetch = vitest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: async () => JSON.stringify({ error: { message: "Resource not found" } }), + }) + global.fetch = mockFetch as typeof fetch + mockResponsesCreate.mockRejectedValue(new Error("SDK unavailable")) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-6-luna", + openAiNativeBaseUrl: "https://custom-openai.example.com/v1/", + }) + + await expect(collectStream(handler.createMessage(systemPrompt, messages))).rejects.toThrow( + 'Responses API returned 404. Verify that the configured base URL exposes /v1/responses and that model "gpt-6-luna" is available to your API account.', + ) + expect(mockFetch).toHaveBeenCalledWith("https://custom-openai.example.com/v1/responses", expect.any(Object)) + }) }) describe("completePrompt", () => { @@ -1451,7 +1471,7 @@ describe("OpenAiNativeHandler", () => { { status: 400, expectedMessage: "Invalid request to Responses API" }, { status: 401, expectedMessage: "Authentication failed" }, { status: 403, expectedMessage: "Access denied" }, - { status: 404, expectedMessage: "Responses API endpoint not found" }, + { status: 404, expectedMessage: "Responses API returned 404" }, { status: 429, expectedMessage: "Rate limit exceeded" }, { status: 500, expectedMessage: "OpenAI service error" }, ] @@ -1486,6 +1506,23 @@ describe("OpenAiNativeHandler", () => { delete (global as any).fetch } }) + + it("preserves Retry-After metadata from an HTTP 429 response", async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: "Token rate limit exceeded" } }), { + status: 429, + headers: { "retry-after-ms": "12500" }, + }), + ) + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-6-luna" }) + await expect(collectStream(handler.createMessage(systemPrompt, messages))).rejects.toMatchObject({ + status: 429, + retryAfterSeconds: 13, + message: expect.stringContaining("Token rate limit exceeded"), + }) + }) }) describe("error telemetry", () => { @@ -2049,6 +2086,32 @@ describe("GPT-5 streaming event coverage (additional)", () => { }).rejects.toThrow("Responses API error: Model overloaded") }) + it("preserves rate-limit classification for in-band Responses API errors", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response( + 'data: {"type":"response.error","error":{"message":"Your requests to gpt-6-luna have exceeded token rate limit.","retry_after_ms":12500}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ) + global.fetch = mockFetch + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-6-luna", + }) + + const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]) + + await expect(collectStream(stream)).rejects.toMatchObject({ + status: 429, + retryAfterSeconds: 13, + message: expect.stringContaining("exceeded token rate limit"), + }) + }) + // New tests: ensure text.verbosity is omitted for models without supportsVerbosity describe("Verbosity gating for non-GPT-5 models", () => { it("should omit text.verbosity for gpt-4.1", async () => { diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 7c45f22180..3e4f3aa7c1 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -576,6 +576,54 @@ describe("OpenAiHandler", () => { expect(callArgs.reasoning_effort).toBe("max") }) + it("should send reasoning_effort none with function tools for OpenAI-compatible reasoning models", async () => { + const reasoningHandler = new OpenAiHandler({ + ...mockOptions, + enableReasoningEffort: true, + openAiCustomModelInfo: { + ...openAiModelInfoSaneDefaults, + supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "none", + }, + }) + + await collectStream( + reasoningHandler.createMessage( + systemPrompt, + messages, + makeCreateMessageMetadata({ + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }), + ), + ) + + expect(mockCreate).toHaveBeenCalledOnce() + expect(mockCreate.mock.calls[0][0]).toEqual( + expect.objectContaining({ + reasoning_effort: "none", + tools: [ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "read_file" }), + }), + ], + }), + ) + }) + it("should not include reasoning_effort when reasoning effort is disabled", async () => { const noReasoningOptions: ApiHandlerOptions = { ...mockOptions, diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index fbbecf5760..895e1a6485 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -34,6 +34,96 @@ import { sanitizeOpenAiCallId } from "../../utils/tool-id" export type OpenAiNativeModel = ReturnType +/** + * The Responses API may report throttling as an in-band stream error while the + * HTTP request itself succeeded. Preserve the status so Task's retry/backoff + * path can distinguish provider throttling from an ordinary stream failure. + */ +function getOpenAiNativeErrorStatus(error: unknown, message: string): number | undefined { + if (error && typeof error === "object") { + const details = error as { status?: unknown; statusCode?: unknown; code?: unknown } + const status = details.status ?? details.statusCode + if (typeof status === "number" && Number.isInteger(status)) { + return status + } + if (details.code === 429) { + return 429 + } + if (typeof details.code === "string" && /rate[_ -]?limit|too[_ -]?many[_ -]?requests/i.test(details.code)) { + return 429 + } + } + + return /rate[\s_-]*limit|too many requests|throttl(?:e|ed|ing)/i.test(message) ? 429 : undefined +} + +function parseOpenAiNativeRetryAfter(value: unknown, milliseconds = false): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return Math.ceil(milliseconds ? value / 1000 : value) + } + if (typeof value !== "string" || !value.trim()) { + return undefined + } + + const normalized = value.trim() + const numeric = Number(normalized) + if (Number.isFinite(numeric) && numeric >= 0) { + return Math.ceil(milliseconds ? numeric / 1000 : numeric) + } + const duration = normalized.match(/^(\d+(?:\.\d+)?)(ms|s)$/i) + if (duration) { + const amount = Number(duration[1]) + return Math.ceil(duration[2].toLowerCase() === "ms" ? amount / 1000 : amount) + } + + const date = Date.parse(normalized) + return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : undefined +} + +function getOpenAiNativeRetryAfterSeconds(error: unknown): number | undefined { + if (!error || typeof error !== "object") { + return undefined + } + + const details = error as { + retryAfter?: unknown + retry_after?: unknown + retryAfterSeconds?: unknown + retry_after_seconds?: unknown + retryAfterMs?: unknown + retry_after_ms?: unknown + } + const seconds = + parseOpenAiNativeRetryAfter(details.retryAfterSeconds) ?? + parseOpenAiNativeRetryAfter(details.retry_after_seconds) ?? + parseOpenAiNativeRetryAfter(details.retryAfter) ?? + parseOpenAiNativeRetryAfter(details.retry_after) ?? + parseOpenAiNativeRetryAfter(details.retryAfterMs, true) ?? + parseOpenAiNativeRetryAfter(details.retry_after_ms, true) + return seconds !== undefined && seconds > 0 ? seconds : undefined +} + +function getOpenAiNativeRetryAfterHeaderSeconds(headers?: Headers): number | undefined { + if (!headers) { + return undefined + } + return ( + parseOpenAiNativeRetryAfter(headers.get("retry-after-ms"), true) ?? + parseOpenAiNativeRetryAfter(headers.get("retry-after")) ?? + parseOpenAiNativeRetryAfter(headers.get("x-ratelimit-reset-tokens")) + ) +} + +function addOpenAiNativeRetryMetadata(error: T, status?: number, retryAfterSeconds?: number): T { + if (status !== undefined) { + Object.assign(error, { status }) + } + if (retryAfterSeconds !== undefined) { + Object.assign(error, { retryAfterSeconds }) + } + return error +} + export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI @@ -562,8 +652,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio _messages?: Anthropic.Messages.MessageParam[], ): ApiStream { const apiKey = this.options.openAiNativeApiKey ?? NOT_PROVIDED - const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" - const url = `${baseUrl}/v1/responses` + const baseUrl = (this.options.openAiNativeBaseUrl || "https://api.openai.com").replace(/\/+$/, "") + // The OpenAI SDK accepts both an origin and a versioned base URL. Mirror + // that behavior in the manual SSE fallback to avoid requesting /v1/v1/responses. + const versionedBaseUrl = /\/v1$/i.test(baseUrl) ? baseUrl : `${baseUrl}/v1` + const url = `${versionedBaseUrl}/responses` // Create AbortController for cancellation this.abortController = new AbortController() @@ -591,10 +684,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio let errorMessage = `OpenAI Responses API request failed (${response.status})` let errorDetails = "" + let errorMetadata: unknown // Try to parse error as JSON for better error messages try { const errorJson = JSON.parse(errorText) + errorMetadata = errorJson.error ?? errorJson if (errorJson.error?.message) { errorDetails = errorJson.error.message } else if (errorJson.message) { @@ -619,8 +714,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio 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." + errorMessage = `Responses API returned 404. Verify that the configured base URL exposes /v1/responses and that model "${model.id}" is available to your API account.` break case 429: errorMessage = "Rate limit exceeded. Please try again later." @@ -639,7 +733,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio errorMessage += ` - ${errorDetails}` } - throw new Error(errorMessage) + throw addOpenAiNativeRetryMetadata( + new Error(errorMessage), + response.status, + getOpenAiNativeRetryAfterHeaderSeconds(response.headers) ?? + getOpenAiNativeRetryAfterSeconds(errorMetadata), + ) } if (!response.body) { @@ -647,20 +746,29 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } // Handle streaming response - yield* this.handleStreamResponse(response.body, model) + yield* this.handleStreamResponse( + response.body, + model, + getOpenAiNativeRetryAfterHeaderSeconds(response.headers), + ) } catch (error) { const model = this.getModel() const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") + const status = getOpenAiNativeErrorStatus(error, errorMessage) + const retryAfterSeconds = getOpenAiNativeRetryAfterSeconds(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage", status) + addOpenAiNativeRetryMetadata(apiError, status, retryAfterSeconds) TelemetryService.instance.captureException(apiError) if (error instanceof Error) { // Re-throw with the original error message if it's already formatted if (error.message.includes("Responses API")) { + addOpenAiNativeRetryMetadata(error, status, retryAfterSeconds) throw error } // Otherwise, wrap it with context - throw new Error(`Failed to connect to Responses API: ${error.message}`) + const wrappedError = new Error(`Failed to connect to Responses API: ${error.message}`) + throw addOpenAiNativeRetryMetadata(wrappedError, status, retryAfterSeconds) } // Handle non-Error objects throw new Error(`Unexpected error connecting to Responses API`) @@ -676,7 +784,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio * and yields structured data chunks (`ApiStream`). It handles a wide variety of event types, * including text deltas, reasoning, usage data, and various status/tool events. */ - private async *handleStreamResponse(body: ReadableStream, model: OpenAiNativeModel): ApiStream { + private async *handleStreamResponse( + body: ReadableStream, + model: OpenAiNativeModel, + responseRetryAfterSeconds?: number, + ): ApiStream { const reader = body.getReader() const decoder = new TextDecoder() let buffer = "" @@ -991,9 +1103,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio else if (parsed.type === "response.error" || parsed.type === "error") { // Error event from the API if (parsed.error || parsed.message) { - throw new Error( - `Responses API error: ${parsed.error?.message || parsed.message || "Unknown error"}`, - ) + const message = parsed.error?.message || parsed.message || "Unknown error" + const streamError = new Error(`Responses API error: ${message}`) + const status = getOpenAiNativeErrorStatus(parsed.error, message) + const retryAfterSeconds = + getOpenAiNativeRetryAfterSeconds(parsed.error) ?? responseRetryAfterSeconds + throw addOpenAiNativeRetryMetadata(streamError, status, retryAfterSeconds) } } // Handle incomplete event @@ -1012,9 +1127,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio else if (parsed.type === "response.failed") { // Response failed if (parsed.error || parsed.message) { - throw new Error( - `Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`, - ) + const message = parsed.error?.message || parsed.message || "Unknown failure" + const streamError = new Error(`Response failed: ${message}`) + const status = getOpenAiNativeErrorStatus(parsed.error, message) + const retryAfterSeconds = + getOpenAiNativeRetryAfterSeconds(parsed.error) ?? responseRetryAfterSeconds + throw addOpenAiNativeRetryMetadata(streamError, status, retryAfterSeconds) } } else if (parsed.type === "response.completed" || parsed.type === "response.done") { // Capture top-level response id @@ -1129,7 +1247,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // No captureException here: the createMessage catch captures this // failure once when the rethrown error propagates. if (error instanceof Error) { - throw new Error(`Error processing response stream: ${error.message}`) + const wrappedError = new Error(`Error processing response stream: ${error.message}`) + const status = getOpenAiNativeErrorStatus(error, error.message) + const retryAfterSeconds = getOpenAiNativeRetryAfterSeconds(error) ?? responseRetryAfterSeconds + throw addOpenAiNativeRetryMetadata(wrappedError, status, retryAfterSeconds) } throw new Error("Unexpected error processing response stream") } finally { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e573a7f107..a6595ce941 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -6046,15 +6046,26 @@ export class Task extends EventEmitter implements TaskLike { rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000)) } - // Prefer RetryInfo on 429 if present + // Treat provider retry hints as a minimum delay. Native Responses API + // streams can report rate limits in-band and include retry-after metadata + // even though the original HTTP status was 200. if (error?.status === 429) { const retryInfo = error?.errorDetails?.find( (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", ) const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) if (match) { - exponentialDelay = Number(match[1]) + 1 + exponentialDelay = Math.max(exponentialDelay, Number(match[1]) + 1) } + const retryAfterSeconds = error?.retryAfterSeconds + if ( + typeof retryAfterSeconds === "number" && + Number.isFinite(retryAfterSeconds) && + retryAfterSeconds > 0 + ) { + exponentialDelay = Math.max(exponentialDelay, Math.ceil(retryAfterSeconds)) + } + exponentialDelay = Math.min(exponentialDelay, MAX_EXPONENTIAL_BACKOFF_SECONDS) } const finalDelay = Math.max(exponentialDelay, rateLimitDelay) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index ad07230204..c03127a98a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3107,7 +3107,7 @@ describe("Cline", () => { vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) // Mock say to track messages - const saySpy = vi.spyOn(cline, "say") + const saySpy = vi.spyOn(cline, "say").mockResolvedValue(undefined) // Create a stream that fails on first chunk const mockError = new Error("API Error") @@ -3272,6 +3272,47 @@ describe("Cline", () => { expect(clock.getLastRequestTime()).toBeDefined() }) + it("honors a provider Retry-After hint for 429 retries", async () => { + // This partial Task double supplies only the backoff method's providerRef, + // apiConfiguration, rateLimitClock, abort, taskId, and instanceId dependencies. + const cline = Object.create(Task.prototype) as unknown as Task + Object.defineProperties(cline, { + providerRef: { value: new WeakRef(mockProvider) }, + apiConfiguration: { value: mockApiConfig }, + rateLimitClock: { value: createRateLimitClock() }, + abort: { value: false, writable: true }, + taskId: { value: "retry-after-test" }, + instanceId: { value: "retry-after-test" }, + }) + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + autoApprovalEnabled: true, + requestDelaySeconds: 3, + }) + const mockDelay = vi.fn().mockResolvedValue(undefined) + vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) + const saySpy = vi.spyOn(cline, "say").mockResolvedValue(undefined) + + const rateLimitError = Object.assign(new Error("Token rate limit exceeded"), { + status: 429, + retryAfterSeconds: 12, + }) + await getTaskTestAccess(cline).backoffAndAnnounce(0, rateLimitError) + + expect(mockDelay).toHaveBeenCalledTimes(12) + const countdownMessages = saySpy.mock.calls.filter( + ([type, text, , partial]) => + type === "api_req_retry_delayed" && partial && typeof text === "string", + ) + expect(countdownMessages.map(([, text]) => text)).toEqual( + Array.from( + { length: 12 }, + (_, index) => `429\nToken rate limit exceeded\n${12 - index}`, + ), + ) + }) + it("should not apply retry delay twice", async () => { const cline = new Task({ provider: mockProvider, diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index ff1b6f59d7..a06a13766c 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -614,6 +614,26 @@ describe("ThinkingBudget", () => { expect(screen.getByTestId("select-item-high")).toBeInTheDocument() }) + it("should enable reasoning and persist 'none' when none is selected", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId("select-item-none")) + + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "none") + }) + it("should show 'xhigh' option when supportsReasoningEffort array includes xhigh (e.g. gpt-5.5)", () => { render( )} diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index ce7337b67a..d61bd85b39 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -420,6 +420,7 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => { const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toEqual([ + "none", "low", "medium", "high",