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
15 changes: 8 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# OpenCode Zen Variables (OpenAI-compatible API)
OPENCODE_API_KEY=""
# Optional base URL override; defaults to https://opencode.ai/zen/v1
OPENCODE_BASE_URL=""
# big-pickle is a reasoning model: reasoning tokens are charged against max_tokens,
# so keep output budgets well above ~1024 or responses come back empty.
MODEL_NAME="big-pickle"
# AI Gateway (OpenAI-compatible chat completions)
# Create a managed key at https://ai-gateway.adityamer.dev/admin/dashboard.
AI_GATEWAY_API_KEY=""
# Include /v1; an optional override for another gateway deployment.
AI_GATEWAY_BASE_URL="https://ai-gateway.adityamer.dev/v1"
# Choose a chat-capable model available to your key via GET /v1/models.
# Provider/model and vendor/model prefixes are accepted unchanged.
MODEL_NAME="claude-sonnet-5"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ For environment setup, read `.env.example`. For deployment, career-page provider

## Feature Rules and Operational Limits

- **AI generation:** `src/lib/gemini.ts` currently calls OpenCode Zen through an OpenAI-compatible API. Use `OPENCODE_API_KEY`, `OPENCODE_BASE_URL`, and `MODEL_NAME` from `.env.example`; the filename is historical. Preserve timeouts, retries, normalized results, user-scoped caches, and regeneration behavior when changing generation flows.
- **AI generation:** `src/lib/gemini.ts` uses the server-only AI Gateway transport in `src/lib/ai-gateway.ts`. Use `AI_GATEWAY_API_KEY`, `AI_GATEWAY_BASE_URL`, and `MODEL_NAME` from `.env.example`; the `gemini.ts` filename is historical. The gateway base includes `/v1` and the app uses chat completions, so choose a chat-capable model reachable by the managed key. Preserve timeouts, retries, normalized results, user-scoped caches, and regeneration behavior when changing generation flows.
- **Drafts and duplicate requests:** inspect `src/hooks/useGenerationFlow.ts`, `src/lib/server-drafts.ts`, and `src/lib/idempotency.ts` before changing restoration or retry behavior. Server drafts and idempotency responses are currently process-local stores, so they are not durable across restarts or shared between instances.
- **Document output:** use the existing PDF parsing, LaTeX generation/rendering, and cover-letter export helpers. Keep upload limits, parsing timeouts, render failures, and downloadable output consistent with the existing flows.
- **Job discovery:** provider adapters live in `src/lib/job-sources/`. Use `src/lib/safe-fetch.ts` for user-supplied URLs and redirects. The initial scan records a baseline without alerting; later scans deduplicate postings and send new-role alerts. Preserve pending notifications when email delivery fails or is unconfigured.
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Application Tracking System

AI-powered resume analysis and cover-letter generation built with Next.js, Stack Auth, Convex, and OpenCode Zen.
AI-powered resume analysis and cover-letter generation built with Next.js, Stack Auth, Convex, and AI Gateway.

## Features

Expand All @@ -22,7 +22,7 @@ AI-powered resume analysis and cover-letter generation built with Next.js, Stack
- Tailwind CSS 4
- Convex (data storage and queries)
- Stack Auth
- OpenCode Zen `big-pickle` (OpenAI-compatible API)
- [AI Gateway](https://ai-gateway.adityamer.dev/docs) (OpenAI-compatible API)
- Radix UI + Lucide icons
- Vitest + Testing Library

Expand All @@ -32,7 +32,7 @@ AI-powered resume analysis and cover-letter generation built with Next.js, Stack

- Node.js 20.19+ (or 22.12+)
- Vite+ (`vp`) and the declared pnpm version
- An OpenCode Zen API key
- An AI Gateway API key
- A Convex project URL
- Stack Auth project credentials

Expand All @@ -55,8 +55,8 @@ cp .env.example .env.local
3. Set required variables in `.env.local`

```env
# OpenCode Zen
OPENCODE_API_KEY=""
# AI Gateway
AI_GATEWAY_API_KEY=""

# Convex
NEXT_PUBLIC_CONVEX_URL=""
Expand All @@ -68,7 +68,8 @@ STACK_SECRET_SERVER_KEY=""

# Optional
NEXT_PUBLIC_APP_URL="http://localhost:3000"
MODEL_NAME="big-pickle"
AI_GATEWAY_BASE_URL="https://ai-gateway.adityamer.dev/v1"
MODEL_NAME="claude-sonnet-5"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
Expand All @@ -91,6 +92,12 @@ AXIOM_SERVICE="ats"
LOG_OBS_ERROR_DETAILS=""
```

Create a managed key from the [gateway dashboard](https://ai-gateway.adityamer.dev/admin/dashboard), then set `AI_GATEWAY_API_KEY` on the server. The app calls `/v1/chat/completions` with bearer authentication; dependency health checks call `/v1/models` on the same base URL. `MODEL_NAME` defaults to `claude-sonnet-5`, the gateway docs' example; select a model your key can reach with `GET /v1/models`. Provider and vendor prefixes (for example `anti/gemini-3-flash`) pass through unchanged.

When migrating, replace `OPENCODE_API_KEY` and `OPENCODE_BASE_URL` in local and deployment environments with the gateway variables. Remove an old `MODEL_NAME=big-pickle` unless your gateway key explicitly exposes that model. The old OpenCode settings are no longer read. Keep keys server-only; never use a `NEXT_PUBLIC_` variable for them.

Use a chat-capable provider/model for this app. A connected Codex subscription seat serves only `/v1/responses`, which this chat integration does not call. See the [gateway API docs](https://ai-gateway.adityamer.dev/docs) for key ownership, model routing, and provider failover.

4. Start Convex dev backend and Next.js app

```bash
Expand Down Expand Up @@ -134,7 +141,7 @@ Choose among seven built-in formats: Jake's Resume, Deedy Modern, SB2Nov ATS, Ex
Modern Sidebar, Graduate Focus, and Compact Technical. Modern Sidebar uses two columns and is
best for direct sharing; the other formats use a single reading column. You can also upload a
custom LaTeX template. Edit the generated source, preview the PDF, download PDF/LaTeX, and save
versions to your history. AI requires `OPENCODE_API_KEY`; PDF previews use `LATEX_RENDER_API_BASE`
versions to your history. AI requires `AI_GATEWAY_API_KEY`; PDF previews use `LATEX_RENDER_API_BASE`
(default: `https://latexonline.cc`).

## Career page watchlist
Expand Down Expand Up @@ -220,13 +227,13 @@ Set `LOG_OBS_ERROR_DETAILS=true` temporarily to include sanitized `errorMessage`

Open **Applications**, then **Prepare for interview** on a tracked role. Choose a saved resume or paste career details and add the job description. Generated questions and answer notes are saved in Convex with the application; removing the application also removes its preparation. Save answer edits explicitly. Regeneration asks before replacing questions and clearing answers. A stale save from another tab returns a conflict so existing work is preserved. Interview records are accessed through the application tracker, rather than the analysis history.

Generation uses the existing OpenCode Zen configuration, validates the AI response, limits each user to six generation requests per minute, and retries once with a 30-second timeout per attempt. Identical inputs reuse saved preparation; forced regeneration replaces it. Concurrent requests are coalesced within one process, and database revision checks protect saves across instances. Pasted inputs are not saved as drafts; questions and explicitly saved answers persist.
Generation uses the existing AI Gateway configuration, validates the AI response, limits each user to six generation requests per minute, and retries once with a 30-second timeout per attempt. Identical inputs reuse saved preparation; forced regeneration replaces it. Concurrent requests are coalesced within one process, and database revision checks protect saves across instances. Pasted inputs are not saved as drafts; questions and explicitly saved answers persist.

## Production readiness

Run `vp run config:check` before deployment. It loads local environment files when present, checks required configuration and public HTTPS URLs, and prints variable names only. Passing this check confirms configuration shape, not successful authentication with each provider.

1. Configure OpenCode Zen, Stack Auth, and the production Convex URL and server key using `.env.example`.
1. Configure AI Gateway, Stack Auth, and the production Convex URL and server key using `.env.example`.
2. Deploy the schema and functions to the intended Convex deployment through its CLI before shipping the frontend. Development deployment: `vp exec convex dev --once`; production deployment: use the project's configured Convex deployment workflow.
3. Set `RESEND_API_KEY` and `EMAIL_FROM` with a verified sender; set `CRON_SECRET` for the existing bounded watchlist cron. Confirm `vercel.json` is active on the hosting platform.
4. Configure both Upstash variables and a public HTTPS `NEXT_PUBLIC_APP_URL`. Keep `ALLOW_IN_MEMORY_RATE_LIMIT` unset in production.
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-production-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ for (const file of [".env.local", ".env"]) {
if (existsSync(file)) process.loadEnvFile(file);
}
const required = [
"OPENCODE_API_KEY",
"AI_GATEWAY_API_KEY",
"NEXT_PUBLIC_CONVEX_URL",
"CONVEX_DEPLOY_KEY",
"NEXT_PUBLIC_STACK_PROJECT_ID",
Expand Down
56 changes: 56 additions & 0 deletions src/lib/ai-gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const DEFAULT_BASE_URL = "https://ai-gateway.adityamer.dev/v1";
const DEFAULT_MODEL = "claude-sonnet-5";
const MAX_OUTPUT_TOKENS = 16384;

export function getAiGatewayConfig() {
return {
apiKey: process.env.AI_GATEWAY_API_KEY?.trim() || "",
baseUrl: (process.env.AI_GATEWAY_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, ""),
model: process.env.MODEL_NAME?.trim() || DEFAULT_MODEL,
};
}

export async function generateGatewayContent(
prompt: string,
temperature: number,
signal?: AbortSignal,
): Promise<string> {
const { apiKey, baseUrl, model } = getAiGatewayConfig();
if (!apiKey) {
throw new Error("AI Gateway API key is not configured. Set AI_GATEWAY_API_KEY on the server.");
}

const configuredTimeout = Number(process.env.AI_TIMEOUT_MS || 30000);
const timeoutMs =
Number.isSafeInteger(configuredTimeout) &&
configuredTimeout > 0 &&
configuredTimeout <= 2147483647
? configuredTimeout
: 30000;
const response = await fetch(baseUrl + "/chat/completions", {
method: "POST",
signal: signal ?? AbortSignal.timeout(timeoutMs),
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + apiKey,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
temperature,
max_tokens: MAX_OUTPUT_TOKENS,
}),
});

// Upstream error bodies may echo source documents or credentials.
if (!response.ok) {
throw new Error(
"AI Gateway API error (" + response.status + "). Check the gateway key and model access.",
);
}

const data = await response.json();
// Reasoning content is provider scratch work, never part of the saved answer.
const content = data?.choices?.[0]?.message?.content;
return typeof content === "string" ? content : "";
}
70 changes: 4 additions & 66 deletions src/lib/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
const apiKey = process.env.OPENCODE_API_KEY;
if (!apiKey) {
console.warn("OPENCODE_API_KEY is not set. AI calls will fail.");
}

const baseUrl = process.env.OPENCODE_BASE_URL || "https://opencode.ai/zen/v1";

const MISSING_KEY_ERROR =
"OpenCode Zen API key is not configured. Please set OPENCODE_API_KEY environment variable.";
import { generateGatewayContent } from "@/lib/ai-gateway";

export const PROMPTS = {
resumeOverview: `You are an experienced Technical Human Resource Manager. Your task is to evaluate the provided resume against the job description.
Expand Down Expand Up @@ -177,63 +169,21 @@ export interface TailoredResumeData {

type ModelTask = AnalysisType | "tailoredResume" | "latexFix" | "interview";

/**
* `big-pickle` is a reasoning model: it returns a `reasoning_content` field
* alongside `content`, and those reasoning tokens are charged against
* `max_tokens`. Budgets under ~1024 are consumed entirely by reasoning and come
* back with empty content, so we stay well above that. The model's hard output
* ceiling is 32k.
*/
const MAX_OUTPUT_TOKENS = 16384;

function temperatureFor(task: ModelTask): number {
if (task === "coverLetter") return 0.8;
if (task === "latexFix") return 0.2;
return 0.4;
}

/**
* Calls OpenCode Zen's OpenAI-compatible `POST /chat/completions` endpoint.
* Plain `fetch` — no provider SDK.
*/
async function generateContent(
task: ModelTask,
prompt: string,
signal?: AbortSignal,
): Promise<string> {
const modelName = process.env.MODEL_NAME || "big-pickle";

const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelName,
messages: [{ role: "user", content: prompt }],
temperature: temperatureFor(task),
max_tokens: MAX_OUTPUT_TOKENS,
}),
});

if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`OpenCode Zen API error (${response.status}): ${detail}`);
}

const data = await response.json();
// Only `content` carries the answer; `reasoning_content` is scratch work.
return data?.choices?.[0]?.message?.content ?? "";
function generateContent(task: ModelTask, prompt: string, signal?: AbortSignal): Promise<string> {
return generateGatewayContent(prompt, temperatureFor(task), signal);
}

export async function generateInterviewQuestions(
resumeText: string,
jobDescription: string,
role: string,
) {
if (!apiKey) throw new Error(MISSING_KEY_ERROR);
const { interviewResultSchema } = await import("@/lib/contracts/api");
const prompt = `Act as an interview coach for ${role}. Return ONLY JSON: {"questions":[{"category":"behavioral|technical|role","question":"...","guidance":"..."}]}.
Create 8 specific questions, covering behavioral, technical (where relevant), and role fit.
Expand Down Expand Up @@ -277,10 +227,6 @@ export async function analyzeResume(
analysisType: AnalysisType,
options?: AnalysisOptions,
): Promise<string> {
if (!apiKey) {
throw new Error(MISSING_KEY_ERROR);
}

let prompt: string;

switch (analysisType) {
Expand Down Expand Up @@ -330,7 +276,7 @@ ${jobDescription}`;

return text;
} catch (error) {
console.error("OpenCode Zen API error:", error);
console.error("AI Gateway API error:", error);
throw error;
}
}
Expand All @@ -339,10 +285,6 @@ export async function generateTailoredResumeData(
resumeText: string,
jobDescription: string,
): Promise<TailoredResumeData> {
if (!apiKey) {
throw new Error(MISSING_KEY_ERROR);
}

const prompt = `You are an expert ATS resume writer.
${jobDescription.trim() ? "Generate tailored resume content from the SOURCE CONTENT and JOB DESCRIPTION." : "Create a general professional resume from the SOURCE CONTENT. There is no target job description. Organize the supplied career details into a clear resume."}

Expand Down Expand Up @@ -421,10 +363,6 @@ export async function fixLatexCompilationError(
latexSource: string,
compileLog?: string,
): Promise<string> {
if (!apiKey) {
throw new Error(MISSING_KEY_ERROR);
}

const boundedLog = (compileLog || "").slice(0, 12000);

const prompt = `You are a strict LaTeX repair assistant.
Expand Down
11 changes: 6 additions & 5 deletions src/lib/health-check.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAiGatewayConfig } from "@/lib/ai-gateway";
import { getClient } from "@/lib/convex-server";
import { stackServerApp } from "@/stack/server";

Expand Down Expand Up @@ -27,13 +28,13 @@ export async function runDependencyHealthChecks(): Promise<{
auth: ServiceHealth;
}> {
const aiStart = Date.now();
let ai: ServiceHealth = { status: process.env.OPENCODE_API_KEY ? "degraded" : "missing" };
if (process.env.OPENCODE_API_KEY) {
const baseUrl = process.env.OPENCODE_BASE_URL || "https://opencode.ai/zen/v1";
const { apiKey, baseUrl } = getAiGatewayConfig();
let ai: ServiceHealth = { status: apiKey ? "degraded" : "missing" };
if (apiKey) {
try {
const response = await withTimeout(
fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${process.env.OPENCODE_API_KEY}` },
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(2000),
}),
2000,
Expand All @@ -44,7 +45,7 @@ export async function runDependencyHealthChecks(): Promise<{
ai = {
status: "degraded",
latencyMs: Date.now() - aiStart,
details: "OpenCode Zen endpoint probe failed",
details: "AI Gateway endpoint probe failed",
};
}
}
Expand Down
Loading
Loading