Skip to content
Open
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
79 changes: 79 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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;

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 });
}
}
77 changes: 63 additions & 14 deletions app/api/issues/feed/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
134 changes: 69 additions & 65 deletions lib/llm/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,13 @@ 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<AIProvider, string> = {
openrouter: "deepseek/deepseek-chat",
groq: "qwen-2.5-coder-32b",
};

const API_BASES: Record<AIProvider, string> = {
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);
Expand All @@ -45,18 +31,6 @@ export function clearConfig(): void {
localStorage.removeItem(STORAGE_KEY);
}

function getBaseHeaders(config: LLMConfig): Record<string, string> {
const headers: Record<string, string> = {
"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
Expand All @@ -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,
}),
});

Expand All @@ -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(
Expand All @@ -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,
}),
});

Expand All @@ -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
}
}
}
Expand All @@ -162,28 +154,40 @@ export async function streamText(

export async function testConnection(config: LLMConfig): Promise<boolean> {
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;
}
Expand Down
Loading