From 760eda92f394375481b8e2f48479f01dde53b2d1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 15:53:50 +0000 Subject: [PATCH] feat(llm): return structured rate-limit info on 429 responses Add RateLimitInfo interface and CompletionResult type union so callers can distinguish 429 rate limits from other errors. Includes isRateLimited type guard and per-request model override in CompletionRequest. Co-Authored-By: Claude Opus 4.6 --- .../llm/__tests__/openRouterClient.test.ts | 54 ++++++++++++++++++- server/src/llm/openRouterClient.ts | 28 ++++++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/server/src/llm/__tests__/openRouterClient.test.ts b/server/src/llm/__tests__/openRouterClient.test.ts index 3518c9e..3234cca 100644 --- a/server/src/llm/__tests__/openRouterClient.test.ts +++ b/server/src/llm/__tests__/openRouterClient.test.ts @@ -62,11 +62,63 @@ describe('openRouterClient', () => { ]); }); - it('returns null on HTTP error', async () => { + it('returns rateLimited result with reset timestamp on 429', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: 'Too Many Requests', + json: () => Promise.resolve({ + error: { + code: 429, + message: 'Rate limit exceeded', + metadata: { + headers: { + 'X-RateLimit-Reset': '1741305600000', + }, + }, + }, + }), + }); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'sys', + user: 'usr', + }); + + expect(result).toEqual({ + rateLimited: true, + resetAt: 1741305600000, + }); + }); + + it('returns rateLimited result with null resetAt when 429 has no reset header', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: () => Promise.resolve({ + error: { code: 429, message: 'Rate limit exceeded' }, + }), + }); + + const client = createOpenRouterClient(mockConfig); + const result = await client.complete({ + system: 'sys', + user: 'usr', + }); + + expect(result).toEqual({ + rateLimited: true, + resetAt: null, + }); + }); + + it('returns null on non-429 HTTP errors', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', }); const client = createOpenRouterClient(mockConfig); diff --git a/server/src/llm/openRouterClient.ts b/server/src/llm/openRouterClient.ts index 4050b88..e98c277 100644 --- a/server/src/llm/openRouterClient.ts +++ b/server/src/llm/openRouterClient.ts @@ -6,15 +6,27 @@ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; export interface CompletionRequest extends RenderedPrompt { maxTokens?: number; temperature?: number; + model?: string; +} + +export interface RateLimitInfo { + rateLimited: true; + resetAt: number | null; +} + +export type CompletionResult = string | null | RateLimitInfo; + +export function isRateLimited(result: CompletionResult): result is RateLimitInfo { + return result != null && typeof result === 'object' && 'rateLimited' in result; } export interface OpenRouterClient { - complete(request: CompletionRequest): Promise; + complete(request: CompletionRequest): Promise; } export function createOpenRouterClient(config: LlmConfig): OpenRouterClient { return { - async complete(request: CompletionRequest): Promise { + async complete(request: CompletionRequest): Promise { try { const response = await fetch(OPENROUTER_URL, { method: 'POST', @@ -24,7 +36,7 @@ export function createOpenRouterClient(config: LlmConfig): OpenRouterClient { 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: config.model, + model: request.model ?? config.model, max_tokens: request.maxTokens ?? config.maxTokens, temperature: request.temperature ?? config.temperature, messages: [ @@ -35,6 +47,16 @@ export function createOpenRouterClient(config: LlmConfig): OpenRouterClient { }); if (!response.ok) { + if (response.status === 429) { + try { + const data = await response.json(); + const resetStr = data?.error?.metadata?.headers?.['X-RateLimit-Reset']; + const resetAt = resetStr ? Number(resetStr) : null; + return { rateLimited: true, resetAt }; + } catch { + return { rateLimited: true, resetAt: null }; + } + } console.warn(`OpenRouter API error: ${response.status} ${response.statusText}`); return null; }