From baf5e1bd4c906ead3375298d2c94783c9aea40a6 Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:21:21 +0530 Subject: [PATCH 1/3] feat(api): implement streaming AI responses using Vercel AI SDK (fixes #48) --- app/api/chat/route.ts | 44 +++++++++++ lib/llm/provider.ts | 134 +++++++++++++++++---------------- package-lock.json | 159 +++++++++++++++++++++++++++++++++++++++ package.json | 4 + tests/rate-limit.test.ts | 6 +- 5 files changed, 279 insertions(+), 68 deletions(-) create mode 100644 app/api/chat/route.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..d7d0372 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import { createOpenAI } from "@ai-sdk/openai"; +import { streamText } from "ai"; + +export const runtime = "edge"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { messages, apiKey, provider, model, system } = body; + + if (!apiKey) { + return Response.json({ error: "No API key provided" }, { status: 401 }); + } + + const baseURL = provider === "openrouter" + ? "https://openrouter.ai/api/v1" + : "https://api.groq.com/openai/v1"; + + const headers = provider === "openrouter" ? { + "HTTP-Referer": "https://reposage.com", + "X-Title": "RepoSage" + } : undefined; + + // Set up custom provider using OpenAI compatibility + const customOpenAI = createOpenAI({ + apiKey, + baseURL, + headers + }); + + const result = streamText({ + model: customOpenAI(model), + system, + messages, + maxTokens: 4096, + }); + + return result.toDataStreamResponse(); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + return Response.json({ error: errorMsg }, { status: 500 }); + } +} diff --git a/lib/llm/provider.ts b/lib/llm/provider.ts index 0b39d44..8c6a04d 100644 --- a/lib/llm/provider.ts +++ b/lib/llm/provider.ts @@ -6,15 +6,6 @@ export interface LLMConfig { model: string; } -interface ChatCompletionChoice { - message?: { content?: string }; - delta?: { content?: string }; -} - -interface ChatCompletionResponse { - choices?: ChatCompletionChoice[]; -} - const STORAGE_KEY = "reposage-llm-config"; export const DEFAULT_MODELS: Record = { @@ -22,11 +13,6 @@ export const DEFAULT_MODELS: Record = { groq: "qwen-2.5-coder-32b", }; -const API_BASES: Record = { - openrouter: "https://openrouter.ai/api/v1/chat/completions", - groq: "https://api.groq.com/openai/v1/chat/completions", -}; - export function getStoredConfig(): LLMConfig | null { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -45,18 +31,6 @@ export function clearConfig(): void { localStorage.removeItem(STORAGE_KEY); } -function getBaseHeaders(config: LLMConfig): Record { - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - }; - if (config.provider === "openrouter") { - headers["HTTP-Referer"] = window.location.origin; - headers["X-Title"] = "RepoSage"; - } - return headers; -} - export async function generateText( prompt: string, system?: string @@ -65,16 +39,19 @@ export async function generateText( if (!config) throw new Error("No AI provider configured"); const messages: { role: string; content: string }[] = []; - if (system) messages.push({ role: "system", content: system }); messages.push({ role: "user", content: prompt }); - const res = await fetch(API_BASES[config.provider], { + const res = await fetch("/api/chat", { method: "POST", - headers: getBaseHeaders(config), + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ + apiKey: config.apiKey, + provider: config.provider, model: config.model, + system, messages, - max_tokens: 4096, }), }); @@ -83,8 +60,33 @@ export async function generateText( throw new Error(`LLM request failed (${res.status}): ${text}`); } - const data = (await res.json()) as ChatCompletionResponse; - return (data.choices?.[0]?.message?.content ?? "").trim(); + // /api/chat streams by default, so we can just read the stream or collect it + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let full = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + for (const line of lines) { + if (line.startsWith('0:')) { + try { + full += JSON.parse(line.slice(2)); + } catch {} + } + } + } + } finally { + try { reader.releaseLock(); } catch {} + } + + return full; } export async function streamText( @@ -96,20 +98,19 @@ export async function streamText( if (!config) throw new Error("No AI provider configured"); const messages: { role: string; content: string }[] = []; - if (system) messages.push({ role: "system", content: system }); messages.push({ role: "user", content: prompt }); - const res = await fetch(API_BASES[config.provider], { + const res = await fetch("/api/chat", { method: "POST", headers: { - ...getBaseHeaders(config), - Accept: "text/event-stream", + "Content-Type": "application/json", }, body: JSON.stringify({ + apiKey: config.apiKey, + provider: config.provider, model: config.model, + system, messages, - max_tokens: 4096, - stream: true, }), }); @@ -123,33 +124,24 @@ export async function streamText( const decoder = new TextDecoder(); let full = ""; - let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data: ")) continue; - - const data = trimmed.slice(6); - if (data === "[DONE]") break; - - try { - const parsed = JSON.parse(data) as ChatCompletionResponse; - const token = parsed.choices?.[0]?.delta?.content ?? ""; - if (token) { + if (line.startsWith('0:')) { + try { + const token = JSON.parse(line.slice(2)); full += token; onToken?.(token); + } catch { + // skip malformed JSON chunks } - } catch { - // skip malformed JSON chunks } } } @@ -162,28 +154,40 @@ export async function streamText( export async function testConnection(config: LLMConfig): Promise { try { - const res = await fetch(API_BASES[config.provider], { + const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - ...(config.provider === "openrouter" - ? { - "HTTP-Referer": window.location.origin, - "X-Title": "RepoSage", - } - : {}), }, body: JSON.stringify({ + apiKey: config.apiKey, + provider: config.provider, model: config.model, messages: [{ role: "user", content: "Reply with only the word: ok" }], - max_tokens: 10, }), }); if (!res.ok) return false; - const data = (await res.json()) as ChatCompletionResponse; - return !!data.choices?.[0]?.message?.content; + // we can just read the whole stream as string + const reader = res.body?.getReader(); + if (!reader) return false; + + const decoder = new TextDecoder(); + let full = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + for (const line of lines) { + if (line.startsWith('0:')) { + try { + full += JSON.parse(line.slice(2)); + } catch {} + } + } + } + return full.length > 0; } catch { return false; } diff --git a/package-lock.json b/package-lock.json index e58047d..48dcf07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,14 @@ "name": "reposage", "version": "0.1.0", "dependencies": { + "@ai-sdk/openai": "^4.0.13", "@base-ui/react": "^1.5.0", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", + "ai": "^7.0.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.40.0", @@ -39,6 +43,69 @@ "vitest": "^2.1.9" } }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.19.tgz", + "integrity": "sha512-pQ3UPO0tyLUuW751jdgWhvHmXmjvAFpyiTeZF6eSVHMYhH/dSH4FtSKGT2nOnvvpaRpU8ygsLV4ng2Mz0lcU/A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.9", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.13.tgz", + "integrity": "sha512-7I2sx9NyT2XJ8YrpBr0twwASv6mgpI9Gu7mNxtevmdJ0n8UxUKtcG1NHNjaOEzN5pKzNAv3XcBOVom9DwJ2CpA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.9" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz", + "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.9.tgz", + "integrity": "sha512-BohlmQ62x+iIOawYCS2z5JzokMq5kZSoVhJawH41mlMdkb01xqhIMG8L0NAByneUFLpdUlSjJjF/bel5j3cGZA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "dev": true, @@ -2206,6 +2273,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3327,6 +3400,48 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -3440,6 +3555,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/acorn": { "version": "8.16.0", "dev": true, @@ -3459,6 +3580,23 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ai": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.26.tgz", + "integrity": "sha512-gzLoOHoXFJNcxrzFyHClbZsDEA1z0LNm3Kwq8lEWIfL+8wEEir+eDJqJJbkEJdLxGrc0IuBkybxEDzkbvSavjw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.19", + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.9" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "dev": true, @@ -5689,6 +5827,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "dev": true, @@ -6637,6 +6784,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, @@ -9269,6 +9422,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "dev": true, diff --git a/package.json b/package.json index e49dbd6..b0e4bac 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "test": "vitest run" }, "dependencies": { + "@ai-sdk/openai": "^4.0.13", "@base-ui/react": "^1.5.0", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", + "ai": "^7.0.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.40.0", diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts index cd27fd9..b61e5a3 100644 --- a/tests/rate-limit.test.ts +++ b/tests/rate-limit.test.ts @@ -25,7 +25,7 @@ describe("getRateLimitInfo", () => { }, }; - const result = await getRateLimitInfo(mockOctokit as any); + const result = await getRateLimitInfo(mockOctokit as unknown as import("@octokit/rest").Octokit); expect(result).toEqual({ remaining: 50, @@ -54,7 +54,7 @@ describe("getRateLimitInfo", () => { }, }; - const result = await getRateLimitInfo(mockOctokit as any); + const result = await getRateLimitInfo(mockOctokit as unknown as import("@octokit/rest").Octokit); expect(result).toEqual({ remaining: 5, @@ -73,7 +73,7 @@ describe("getRateLimitInfo", () => { }, }; - const result = await getRateLimitInfo(mockOctokit as any); + const result = await getRateLimitInfo(mockOctokit as unknown as import("@octokit/rest").Octokit); expect(result).toBeNull(); }); From 619ff51981b9829f59f2318573fc1fba43bd1ac1 Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:23:22 +0530 Subject: [PATCH 2/3] feat(api): implement upstash redis rate limiting on LLM routes (fixes #49) --- app/api/chat/route.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index d7d0372..dcb8a70 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,11 +1,46 @@ import { NextRequest } from "next/server"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; import { createOpenAI } from "@ai-sdk/openai"; import { streamText } from "ai"; export const runtime = "edge"; +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL || "", + token: process.env.UPSTASH_REDIS_REST_TOKEN || "", +}); + +// Create a new ratelimiter, that allows 10 requests per 1 minute +const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(10, "1 m"), + analytics: true, +}); + export async function POST(req: NextRequest) { try { + const ip = req.ip ?? "127.0.0.1"; + + // Skip rate limiting if keys are missing (for local dev without redis) + if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { + const { success, limit, reset, remaining } = await ratelimit.limit(`ratelimit_${ip}`); + + if (!success) { + return Response.json( + { error: "Too many requests. Please try again later." }, + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString() + } + } + ); + } + } + const body = await req.json(); const { messages, apiKey, provider, model, system } = body; From 347d9a855a749e6a3ad04446cefe60c13cf686b7 Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:01:49 +0530 Subject: [PATCH 3/3] feat(api): Implement Zod runtime validation and strict rate limiting (fixes #52) --- app/api/issues/feed/route.ts | 77 +++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/app/api/issues/feed/route.ts b/app/api/issues/feed/route.ts index 9b6fab0..eaae433 100644 --- a/app/api/issues/feed/route.ts +++ b/app/api/issues/feed/route.ts @@ -1,22 +1,71 @@ +import { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { createOctokit } from "@/lib/github"; import { fetchGoodFirstIssues } from "@/lib/github/issues"; +import { z } from "zod"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; -export async function POST(req: Request) { - const session = await auth(); - if (!session?.accessToken) { - return Response.json({ issues: [], reposWithIssues: 0 }); - } +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL || "", + token: process.env.UPSTASH_REDIS_REST_TOKEN || "", +}); - const { languages, page } = await req.json(); - if (!Array.isArray(languages)) { - return Response.json({ issues: [], reposWithIssues: 0 }); - } +const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(10, "1 m"), + analytics: true, +}); + +const FeedRequestSchema = z.object({ + languages: z.array(z.string()), + page: z.number().optional().default(1), +}); + +export async function POST(req: NextRequest) { + try { + const ip = req.ip ?? "127.0.0.1"; + + if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { + const { success, limit, reset, remaining } = await ratelimit.limit(`ratelimit_feed_${ip}`); + + if (!success) { + return Response.json( + { error: "Too many requests. Please try again later." }, + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString() + } + } + ); + } + } - const octokit = createOctokit(session.accessToken); - const feed = await fetchGoodFirstIssues(octokit, languages, { page: page ?? 1 }).catch( - () => ({ issues: [], reposWithIssues: 0 }) - ); + const session = await auth(); + if (!session?.accessToken) { + return Response.json({ issues: [], reposWithIssues: 0 }); + } - return Response.json(feed); + const body = await req.json(); + const parsed = FeedRequestSchema.safeParse(body); + + if (!parsed.success) { + return Response.json({ error: "Invalid request payload", details: parsed.error.format() }, { status: 400 }); + } + + const { languages, page } = parsed.data; + + const octokit = createOctokit(session.accessToken); + const feed = await fetchGoodFirstIssues(octokit, languages, { page }).catch( + () => ({ issues: [], reposWithIssues: 0 }) + ); + + return Response.json(feed); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + return Response.json({ error: errorMsg }, { status: 500 }); + } }