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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ GITHUB_TOKEN=
# --- Quick LLM (GET prompt / reverse-prompt API) ---
# Pick the provider in one line. Keys for other providers can stay in the file; only the selected one is used.
# GITREVERSE_QUICK_LLM=auto
# auto — first key wins: Grok → OpenRouter → Azure → Google (same as leaving this unset)
# grok | openrouter | azure | google — require that provider’s API key only
# auto — first key wins: Grok → OpenRouter → OrcaRouter → Azure → Google (same as leaving this unset)
# grok | openrouter | orcarouter | azure | google — require that provider’s API key only

# XAI_API_KEY=
# XAI_MODEL=grok-3 # or e.g. grok-4.20-0309-non-reasoning

OPENROUTER_API_KEY=
# OPENROUTER_MODEL=google/gemini-2.5-pro

ORCAROUTER_API_KEY=
# ORCAROUTER_MODEL=anthropic/claude-sonnet-5

# Azure OpenAI / Foundry OpenAI-compatible endpoint, e.g. https://your-resource.services.ai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_BASE_URL=
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ Copy `.env.example` to `.env.local` and fill in at least one LLM API key.

### Quick LLM (required)

The quick reverse endpoint supports four providers. Set **`GITREVERSE_QUICK_LLM`** to pin one, or leave it unset (`auto`) to let the app use whichever key it finds first:
The quick reverse endpoint supports five providers. Set **`GITREVERSE_QUICK_LLM`** to pin one, or leave it unset (`auto`) to let the app use whichever key it finds first:

| Provider | Key env var | Model env var | Default model |
|---|---|---|---|
| Grok (xAI) | `XAI_API_KEY` | `XAI_MODEL` | `grok-3` |
| OpenRouter | `OPENROUTER_API_KEY` | `OPENROUTER_MODEL` | `google/gemini-2.5-pro` |
| OrcaRouter | `ORCAROUTER_API_KEY` | `ORCAROUTER_MODEL` | `anthropic/claude-sonnet-5` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` | `AZURE_OPENAI_MODEL` | `gpt-5.4` |
| Google AI Studio | `GOOGLE_GENERATIVE_AI_API_KEY` | `GOOGLE_AI_STUDIO_MODEL` | `gemini-2.5-pro` |

In `auto` mode the order of preference is: Grok → OpenRouter → Azure → Google.
In `auto` mode the order of preference is: Grok → OpenRouter → OrcaRouter → Azure → Google.

[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible gateway that also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes. Its model ids are namespaced (e.g. `anthropic/claude-sonnet-5`), so the default above uses one.

Azure quick reverse uses `gpt-5.4` by default with `AZURE_OPENAI_REASONING_EFFORT=medium`. Title generation also uses Azure and defaults to `gpt-5.4-mini` with reasoning disabled.

Expand Down
50 changes: 50 additions & 0 deletions app/api/game-spec/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidGameSlug } from "@/lib/parse-game-input";
import { readSpecMd } from "@/lib/game-reverse-storage";

export const runtime = "nodejs";

type RouteContext = { params: Promise<{ slug: string }> };

export async function GET(request: NextRequest, context: RouteContext) {
const { slug: rawSlug } = await context.params;
const slug = rawSlug.trim().toLowerCase();

if (!isValidGameSlug(slug)) {
return NextResponse.json({ error: "Invalid slug." }, { status: 400 });
}

const specMd = await readSpecMd(slug);
if (!specMd) {
return NextResponse.json(
{ error: "GAME.md not found. Run game reverse first." },
{ status: 404 }
);
}

const download = request.nextUrl.searchParams.has("download");
const headers: Record<string, string> = {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=86400, s-maxage=86400",
"Access-Control-Allow-Origin": "*",
};
if (download) {
headers["Content-Disposition"] = `attachment; filename="${slug}-GAME.md"`;
}

return new NextResponse(specMd, {
status: 200,
headers,
});
}

export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
2 changes: 1 addition & 1 deletion app/api/library/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const LIMIT = 24;

function parseKindFilter(raw: string | null): LibraryKindFilter {
const v = raw?.trim().toLowerCase();
if (v === "code" || v === "website") return v;
if (v === "code" || v === "website" || v === "game") return v;
return "all";
}

Expand Down
164 changes: 164 additions & 0 deletions app/api/reverse-game/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidGameSlug } from "@/lib/parse-game-input";
import {
ensureGameReversed,
type GameReverseResult,
} from "@/lib/game-reverse-engine";
import { readGameReverse } from "@/lib/game-reverse-storage";

export const runtime = "nodejs";
export const maxDuration = 300;

const ROUTE_TIMEOUT_MS = 240_000;
const inFlight = new Map<string, Promise<GameReverseResult>>();

function encodeSse(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}

async function executeGameReverse(opts: {
slug: string;
gameName: string;
stream: boolean;
force?: boolean;
}): Promise<NextResponse> {
const { slug, gameName, stream, force } = opts;

if (!stream) {
const existing = inFlight.get(slug);
if (existing) {
const result = await existing;
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({
prompt: result.prompt,
specPath: result.specPath,
fromCache: result.fromCache,
});
}

const promise = ensureGameReversed({ slug, gameName, force });
inFlight.set(slug, promise);
try {
const result = await promise;
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({
prompt: result.prompt,
specPath: result.specPath,
fromCache: result.fromCache,
});
} finally {
inFlight.delete(slug);
}
}

const encoder = new TextEncoder();
const streamBody = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(encodeSse(event, data)));
};

try {
const result = await ensureGameReversed({
slug,
gameName,
force,
onStatus: (message) => send("status", { message }),
});

if (!result.ok) {
send("error", { error: result.error });
controller.close();
return;
}

send("done", {
prompt: result.prompt,
specPath: result.specPath,
fromCache: result.fromCache,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
send("error", { error: msg });
} finally {
controller.close();
}
},
});

return new NextResponse(streamBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

export async function POST(request: NextRequest) {
let body: {
gameSlug?: string;
gameName?: string;
stream?: boolean;
force?: boolean;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const gameSlug = body.gameSlug?.trim().toLowerCase();
if (!gameSlug || !isValidGameSlug(gameSlug)) {
return NextResponse.json(
{ error: "gameSlug is required and must be a valid slug." },
{ status: 400 }
);
}

let gameName = body.gameName?.trim();
if (!gameName) {
const cached = await readGameReverse(gameSlug);
if (cached?.meta.gameName) {
gameName = cached.meta.gameName;
}
}

if (!gameName) {
return NextResponse.json(
{ error: "gameName is required for the first run." },
{ status: 400 }
);
}

const timer = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("__timeout__")), ROUTE_TIMEOUT_MS)
);

try {
return await Promise.race([
executeGameReverse({
slug: gameSlug,
gameName,
stream: body.stream === true,
force: body.force === true,
}),
timer,
]);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === "__timeout__") {
return NextResponse.json(
{ error: "Game reverse timed out. Try again." },
{ status: 504 }
);
}
return NextResponse.json({ error: msg }, { status: 500 });
}
}
Loading