Skip to content

Commit a80a74a

Browse files
authored
Capture more of OpenRouter's provider specific error details (#10073)
* Capture more of OpenRouter's provider specific error details * Actually match the openrouter structure
1 parent 9f3122f commit a80a74a

File tree

1 file changed

+139
-21
lines changed

1 file changed

+139
-21
lines changed

src/api/providers/openrouter.ts

Lines changed: 139 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
3+
import { z } from "zod"
34

45
import {
56
openRouterDefaultModelId,
@@ -42,13 +43,77 @@ type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
4243
reasoning?: OpenRouterReasoningParams
4344
}
4445

45-
// OpenRouter error structure that may include metadata.raw with actual upstream error
46+
// Zod schema for OpenRouter error response structure (for caught exceptions)
47+
const OpenRouterErrorResponseSchema = z.object({
48+
error: z
49+
.object({
50+
message: z.string().optional(),
51+
code: z.number().optional(),
52+
metadata: z
53+
.object({
54+
raw: z.string().optional(),
55+
})
56+
.optional(),
57+
})
58+
.optional(),
59+
})
60+
61+
// OpenRouter error structure that may include error.metadata.raw with actual upstream error
62+
// This is for caught exceptions which have the error wrapped in an "error" property
4663
interface OpenRouterErrorResponse {
64+
error?: {
65+
message?: string
66+
code?: number
67+
metadata?: { raw?: string }
68+
}
69+
}
70+
71+
// Direct error object structure (for streaming errors passed directly)
72+
interface OpenRouterError {
4773
message?: string
4874
code?: number
4975
metadata?: { raw?: string }
5076
}
5177

78+
/**
79+
* Helper function to parse and extract error message from metadata.raw
80+
* metadata.raw is often a JSON encoded string that may contain .message or .error fields
81+
* Example structures:
82+
* - {"message": "Error text"}
83+
* - {"error": "Error text"}
84+
* - {"error": {"message": "Error text"}}
85+
* - {"type":"error","error":{"type":"invalid_request_error","message":"tools: Tool names must be unique."}}
86+
*/
87+
function extractErrorFromMetadataRaw(raw: string | undefined): string | undefined {
88+
if (!raw) {
89+
return undefined
90+
}
91+
92+
try {
93+
const parsed = JSON.parse(raw)
94+
// Check for common error message fields
95+
if (typeof parsed === "object" && parsed !== null) {
96+
// Check for direct message field
97+
if (typeof parsed.message === "string") {
98+
return parsed.message
99+
}
100+
// Check for nested error.message field (e.g., Anthropic error format)
101+
if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") {
102+
return parsed.error.message
103+
}
104+
// Check for error as a string
105+
if (typeof parsed.error === "string") {
106+
return parsed.error
107+
}
108+
}
109+
// If we can't extract a specific field, return the raw string
110+
return raw
111+
} catch {
112+
// If it's not valid JSON, return as-is
113+
return raw
114+
}
115+
}
116+
52117
// See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]`
53118
// `CompletionsAPI.CompletionUsage`
54119
// See also: https://openrouter.ai/docs/use-cases/usage-accounting
@@ -119,19 +184,16 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
119184
/**
120185
* Handle OpenRouter streaming error response and report to telemetry.
121186
* OpenRouter may include metadata.raw with the actual upstream provider error.
187+
* @param error The error object (not wrapped - receives the error directly)
122188
*/
123-
private handleStreamingError(error: OpenRouterErrorResponse, modelId: string, operation: string): never {
124-
const rawErrorMessage = error?.metadata?.raw || error?.message
189+
private handleStreamingError(error: OpenRouterError, modelId: string, operation: string): never {
190+
const rawString = error?.metadata?.raw
191+
const parsedError = extractErrorFromMetadataRaw(rawString)
192+
const rawErrorMessage = parsedError || error?.message || "Unknown error"
125193

126194
const apiError = Object.assign(
127-
new ApiProviderError(
128-
rawErrorMessage ?? "Unknown error",
129-
this.providerName,
130-
modelId,
131-
operation,
132-
error?.code,
133-
),
134-
{ status: error?.code, error: { message: error?.message, metadata: error?.metadata } },
195+
new ApiProviderError(rawErrorMessage, this.providerName, modelId, operation, error?.code),
196+
{ status: error?.code, error },
135197
)
136198

137199
TelemetryService.instance.captureException(apiError)
@@ -256,10 +318,38 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
256318
try {
257319
stream = await this.client.chat.completions.create(completionParams, requestOptions)
258320
} catch (error) {
259-
const errorMessage = error instanceof Error ? error.message : String(error)
260-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
261-
TelemetryService.instance.captureException(apiError)
262-
throw handleOpenAIError(error, this.providerName)
321+
// Try to parse as OpenRouter error structure using Zod
322+
const parseResult = OpenRouterErrorResponseSchema.safeParse(error)
323+
324+
if (parseResult.success && parseResult.data.error) {
325+
const openRouterError = parseResult.data
326+
const rawString = openRouterError.error?.metadata?.raw
327+
const parsedError = extractErrorFromMetadataRaw(rawString)
328+
const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error"
329+
330+
const apiError = Object.assign(
331+
new ApiProviderError(
332+
rawErrorMessage,
333+
this.providerName,
334+
modelId,
335+
"createMessage",
336+
openRouterError.error?.code,
337+
),
338+
{
339+
status: openRouterError.error?.code,
340+
error: openRouterError.error,
341+
},
342+
)
343+
344+
TelemetryService.instance.captureException(apiError)
345+
throw handleOpenAIError(error, this.providerName)
346+
} else {
347+
// Fallback for non-OpenRouter errors
348+
const errorMessage = error instanceof Error ? error.message : String(error)
349+
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
350+
TelemetryService.instance.captureException(apiError)
351+
throw handleOpenAIError(error, this.providerName)
352+
}
263353
}
264354

265355
let lastUsage: CompletionUsage | undefined = undefined
@@ -281,7 +371,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
281371
for await (const chunk of stream) {
282372
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
283373
if ("error" in chunk) {
284-
this.handleStreamingError(chunk.error as OpenRouterErrorResponse, modelId, "createMessage")
374+
this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage")
285375
}
286376

287377
const delta = chunk.choices[0]?.delta
@@ -486,14 +576,42 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
486576
try {
487577
response = await this.client.chat.completions.create(completionParams, requestOptions)
488578
} catch (error) {
489-
const errorMessage = error instanceof Error ? error.message : String(error)
490-
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
491-
TelemetryService.instance.captureException(apiError)
492-
throw handleOpenAIError(error, this.providerName)
579+
// Try to parse as OpenRouter error structure using Zod
580+
const parseResult = OpenRouterErrorResponseSchema.safeParse(error)
581+
582+
if (parseResult.success && parseResult.data.error) {
583+
const openRouterError = parseResult.data
584+
const rawString = openRouterError.error?.metadata?.raw
585+
const parsedError = extractErrorFromMetadataRaw(rawString)
586+
const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error"
587+
588+
const apiError = Object.assign(
589+
new ApiProviderError(
590+
rawErrorMessage,
591+
this.providerName,
592+
modelId,
593+
"completePrompt",
594+
openRouterError.error?.code,
595+
),
596+
{
597+
status: openRouterError.error?.code,
598+
error: openRouterError.error,
599+
},
600+
)
601+
602+
TelemetryService.instance.captureException(apiError)
603+
throw handleOpenAIError(error, this.providerName)
604+
} else {
605+
// Fallback for non-OpenRouter errors
606+
const errorMessage = error instanceof Error ? error.message : String(error)
607+
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
608+
TelemetryService.instance.captureException(apiError)
609+
throw handleOpenAIError(error, this.providerName)
610+
}
493611
}
494612

495613
if ("error" in response) {
496-
this.handleStreamingError(response.error as OpenRouterErrorResponse, modelId, "completePrompt")
614+
this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt")
497615
}
498616

499617
const completion = response as OpenAI.Chat.ChatCompletion

0 commit comments

Comments
 (0)