Skip to content

Commit fa056dc

Browse files
committed
fix: improve OpenRouter error handling and add timeout mechanism
- Add detailed error logging with model ID and error context - Provide user-friendly error messages for common issues (model not found, rate limit, auth) - Add 30-second timeout mechanism to handle hanging streams - Add comprehensive test coverage for error scenarios Fixes #6232
1 parent 0504199 commit fa056dc

File tree

2 files changed

+160
-31
lines changed

2 files changed

+160
-31
lines changed

src/api/providers/__tests__/openrouter.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,42 @@ describe("OpenRouterHandler", () => {
265265
const generator = handler.createMessage("test", [])
266266
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error")
267267
})
268+
269+
it("handles model not found errors with user-friendly message", async () => {
270+
const handler = new OpenRouterHandler(mockOptions)
271+
const mockStream = {
272+
async *[Symbol.asyncIterator]() {
273+
yield { error: { message: "Model not found", code: 404 } }
274+
},
275+
}
276+
277+
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
278+
;(OpenAI as any).prototype.chat = {
279+
completions: { create: mockCreate },
280+
} as any
281+
282+
const generator = handler.createMessage("test", [])
283+
await expect(generator.next()).rejects.toThrow(
284+
`Model "${mockOptions.openRouterModelId}" is not available on OpenRouter`,
285+
)
286+
})
287+
288+
it("handles rate limit errors with user-friendly message", async () => {
289+
const handler = new OpenRouterHandler(mockOptions)
290+
const mockStream = {
291+
async *[Symbol.asyncIterator]() {
292+
yield { error: { message: "Rate limit exceeded", code: 429 } }
293+
},
294+
}
295+
296+
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
297+
;(OpenAI as any).prototype.chat = {
298+
completions: { create: mockCreate },
299+
} as any
300+
301+
const generator = handler.createMessage("test", [])
302+
await expect(generator.next()).rejects.toThrow("OpenRouter rate limit exceeded")
303+
})
268304
})
269305

270306
describe("completePrompt", () => {
@@ -308,6 +344,25 @@ describe("OpenRouterHandler", () => {
308344
await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error")
309345
})
310346

347+
it("handles model not found errors in completePrompt", async () => {
348+
const handler = new OpenRouterHandler(mockOptions)
349+
const mockError = {
350+
error: {
351+
message: "Invalid model",
352+
code: 404,
353+
},
354+
}
355+
356+
const mockCreate = vitest.fn().mockResolvedValue(mockError)
357+
;(OpenAI as any).prototype.chat = {
358+
completions: { create: mockCreate },
359+
} as any
360+
361+
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
362+
`Model "${mockOptions.openRouterModelId}" is not available on OpenRouter`,
363+
)
364+
})
365+
311366
it("handles unexpected errors", async () => {
312367
const handler = new OpenRouterHandler(mockOptions)
313368
const mockCreate = vitest.fn().mockRejectedValue(new Error("Unexpected error"))

src/api/providers/openrouter.ts

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -137,39 +137,85 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
137137
const stream = await this.client.chat.completions.create(completionParams)
138138

139139
let lastUsage: CompletionUsage | undefined = undefined
140-
141-
for await (const chunk of stream) {
142-
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
143-
if ("error" in chunk) {
144-
const error = chunk.error as { message?: string; code?: number }
145-
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
146-
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
147-
}
148-
149-
const delta = chunk.choices[0]?.delta
150-
151-
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
152-
yield { type: "reasoning", text: delta.reasoning }
153-
}
154-
155-
if (delta?.content) {
156-
yield { type: "text", text: delta.content }
140+
let lastChunkTime = Date.now()
141+
const CHUNK_TIMEOUT_MS = 30000 // 30 seconds timeout between chunks
142+
143+
// Set up a timeout check
144+
const timeoutCheck = setInterval(() => {
145+
const timeSinceLastChunk = Date.now() - lastChunkTime
146+
if (timeSinceLastChunk > CHUNK_TIMEOUT_MS) {
147+
clearInterval(timeoutCheck)
148+
console.error(`OpenRouter stream timeout: No chunks received for ${CHUNK_TIMEOUT_MS}ms`, {
149+
modelId,
150+
timeSinceLastChunk,
151+
})
157152
}
158-
159-
if (chunk.usage) {
160-
lastUsage = chunk.usage
153+
}, 5000) // Check every 5 seconds
154+
155+
try {
156+
for await (const chunk of stream) {
157+
lastChunkTime = Date.now() // Reset timeout on each chunk
158+
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
159+
if ("error" in chunk) {
160+
const error = chunk.error as { message?: string; code?: number; type?: string }
161+
const errorMessage = error?.message || "Unknown error"
162+
const errorCode = error?.code || "unknown"
163+
const errorType = error?.type || "unknown"
164+
165+
// Log detailed error information
166+
console.error(`OpenRouter API Error:`, {
167+
code: errorCode,
168+
type: errorType,
169+
message: errorMessage,
170+
modelId,
171+
chunk: JSON.stringify(chunk),
172+
})
173+
174+
// Provide more specific error messages for common issues
175+
let userFriendlyMessage = `OpenRouter API Error ${errorCode}: ${errorMessage}`
176+
177+
if (
178+
errorMessage.toLowerCase().includes("model not found") ||
179+
errorMessage.toLowerCase().includes("invalid model") ||
180+
errorCode === 404
181+
) {
182+
userFriendlyMessage = `Model "${modelId}" is not available on OpenRouter. Please check if the model ID is correct and if you have access to this model.`
183+
} else if (errorMessage.toLowerCase().includes("rate limit")) {
184+
userFriendlyMessage = `OpenRouter rate limit exceeded. Please wait a moment and try again.`
185+
} else if (errorMessage.toLowerCase().includes("unauthorized") || errorCode === 401) {
186+
userFriendlyMessage = `OpenRouter authentication failed. Please check your API key.`
187+
}
188+
189+
throw new Error(userFriendlyMessage)
190+
}
191+
192+
const delta = chunk.choices[0]?.delta
193+
194+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
195+
yield { type: "reasoning", text: delta.reasoning }
196+
}
197+
198+
if (delta?.content) {
199+
yield { type: "text", text: delta.content }
200+
}
201+
202+
if (chunk.usage) {
203+
lastUsage = chunk.usage
204+
}
161205
}
162-
}
163206

164-
if (lastUsage) {
165-
yield {
166-
type: "usage",
167-
inputTokens: lastUsage.prompt_tokens || 0,
168-
outputTokens: lastUsage.completion_tokens || 0,
169-
cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens,
170-
reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens,
171-
totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0),
207+
if (lastUsage) {
208+
yield {
209+
type: "usage",
210+
inputTokens: lastUsage.prompt_tokens || 0,
211+
outputTokens: lastUsage.completion_tokens || 0,
212+
cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens,
213+
reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens,
214+
totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0),
215+
}
172216
}
217+
} finally {
218+
clearInterval(timeoutCheck)
173219
}
174220
}
175221

@@ -235,8 +281,36 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
235281
const response = await this.client.chat.completions.create(completionParams)
236282

237283
if ("error" in response) {
238-
const error = response.error as { message?: string; code?: number }
239-
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
284+
const error = response.error as { message?: string; code?: number; type?: string }
285+
const errorMessage = error?.message || "Unknown error"
286+
const errorCode = error?.code || "unknown"
287+
const errorType = error?.type || "unknown"
288+
289+
// Log detailed error information
290+
console.error(`OpenRouter API Error:`, {
291+
code: errorCode,
292+
type: errorType,
293+
message: errorMessage,
294+
modelId,
295+
response: JSON.stringify(response),
296+
})
297+
298+
// Provide more specific error messages for common issues
299+
let userFriendlyMessage = `OpenRouter API Error ${errorCode}: ${errorMessage}`
300+
301+
if (
302+
errorMessage.toLowerCase().includes("model not found") ||
303+
errorMessage.toLowerCase().includes("invalid model") ||
304+
errorCode === 404
305+
) {
306+
userFriendlyMessage = `Model "${modelId}" is not available on OpenRouter. Please check if the model ID is correct and if you have access to this model.`
307+
} else if (errorMessage.toLowerCase().includes("rate limit")) {
308+
userFriendlyMessage = `OpenRouter rate limit exceeded. Please wait a moment and try again.`
309+
} else if (errorMessage.toLowerCase().includes("unauthorized") || errorCode === 401) {
310+
userFriendlyMessage = `OpenRouter authentication failed. Please check your API key.`
311+
}
312+
313+
throw new Error(userFriendlyMessage)
240314
}
241315

242316
const completion = response as OpenAI.Chat.ChatCompletion

0 commit comments

Comments
 (0)