Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" },
]
Expand Down Expand Up @@ -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<typeof fetch>().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", () => {
Expand Down Expand Up @@ -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<typeof fetch>()
.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 () => {
Expand Down
48 changes: 48 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 137 additions & 16 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,96 @@ import { sanitizeOpenAiCallId } from "../../utils/tool-id"

export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>

/**
* 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<T extends Error>(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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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."
Expand All @@ -639,28 +733,42 @@ 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) {
throw new Error("Responses API error: No response body")
}

// 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`)
Expand All @@ -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<Uint8Array>, model: OpenAiNativeModel): ApiStream {
private async *handleStreamResponse(
body: ReadableStream<Uint8Array>,
model: OpenAiNativeModel,
responseRetryAfterSeconds?: number,
): ApiStream {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading