diff --git a/.env.example b/.env.example index 898af30..6bd5d85 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 4a2fc0e..debd339 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 350e3a9..0b4dfdd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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="" @@ -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" @@ -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 @@ -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 @@ -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. diff --git a/scripts/check-production-config.mjs b/scripts/check-production-config.mjs index 3612dff..32be406 100644 --- a/scripts/check-production-config.mjs +++ b/scripts/check-production-config.mjs @@ -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", diff --git a/src/lib/ai-gateway.ts b/src/lib/ai-gateway.ts new file mode 100644 index 0000000..b630143 --- /dev/null +++ b/src/lib/ai-gateway.ts @@ -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 { + 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 : ""; +} diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index f2559a9..3d7c13f 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -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. @@ -177,55 +169,14 @@ 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 { - 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 { + return generateGatewayContent(prompt, temperatureFor(task), signal); } export async function generateInterviewQuestions( @@ -233,7 +184,6 @@ export async function generateInterviewQuestions( 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. @@ -277,10 +227,6 @@ export async function analyzeResume( analysisType: AnalysisType, options?: AnalysisOptions, ): Promise { - if (!apiKey) { - throw new Error(MISSING_KEY_ERROR); - } - let prompt: string; switch (analysisType) { @@ -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; } } @@ -339,10 +285,6 @@ export async function generateTailoredResumeData( resumeText: string, jobDescription: string, ): Promise { - 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."} @@ -421,10 +363,6 @@ export async function fixLatexCompilationError( latexSource: string, compileLog?: string, ): Promise { - if (!apiKey) { - throw new Error(MISSING_KEY_ERROR); - } - const boundedLog = (compileLog || "").slice(0, 12000); const prompt = `You are a strict LaTeX repair assistant. diff --git a/src/lib/health-check.ts b/src/lib/health-check.ts index 8ced92f..072e689 100644 --- a/src/lib/health-check.ts +++ b/src/lib/health-check.ts @@ -1,3 +1,4 @@ +import { getAiGatewayConfig } from "@/lib/ai-gateway"; import { getClient } from "@/lib/convex-server"; import { stackServerApp } from "@/stack/server"; @@ -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, @@ -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", }; } } diff --git a/test/lib/ai-gateway.test.ts b/test/lib/ai-gateway.test.ts new file mode 100644 index 0000000..6228d52 --- /dev/null +++ b/test/lib/ai-gateway.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { generateGatewayContent } from "@/lib/ai-gateway"; + +const fetchMock = vi.fn(); +function completion(message: unknown) { + return new Response(JSON.stringify({ choices: [{ message }] }), { status: 200 }); +} + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("AI_GATEWAY_API_KEY", "sk-test-managed-key"); + vi.stubEnv("AI_GATEWAY_BASE_URL", ""); + vi.stubEnv("MODEL_NAME", ""); + vi.stubEnv("AI_TIMEOUT_MS", ""); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("AI Gateway transport", () => { + it("uses the documented chat endpoint, managed key, and default model", async () => { + fetchMock.mockResolvedValue(completion({ content: "Answer" })); + expect(await generateGatewayContent("Resume prompt", 0.4)).toBe("Answer"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://ai-gateway.adityamer.dev/v1/chat/completions"); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer sk-test-managed-key", + }); + expect(JSON.parse(init.body)).toEqual({ + model: "claude-sonnet-5", + messages: [{ role: "user", content: "Resume prompt" }], + temperature: 0.4, + max_tokens: 16384, + }); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("normalizes the base URL and preserves provider-prefixed model names", async () => { + vi.stubEnv("AI_GATEWAY_BASE_URL", " https://gateway.example/v1/// "); + vi.stubEnv("MODEL_NAME", "anti/gemini-3-flash"); + fetchMock.mockResolvedValue(completion({ content: "Answer" })); + await generateGatewayContent("Prompt", 0.8); + expect(fetchMock.mock.calls[0][0]).toBe("https://gateway.example/v1/chat/completions"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe("anti/gemini-3-flash"); + }); + + it("requires a gateway key even if a legacy OpenCode key exists", async () => { + vi.stubEnv("AI_GATEWAY_API_KEY", " "); + vi.stubEnv("OPENCODE_API_KEY", "legacy-only"); + await expect(generateGatewayContent("Prompt", 0.4)).rejects.toThrow("Set AI_GATEWAY_API_KEY"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports HTTP failures without exposing an upstream error body", async () => { + const text = vi.fn().mockResolvedValue("Secret key and candidate resume text"); + fetchMock.mockResolvedValue({ ok: false, status: 401, text }); + await expect(generateGatewayContent("Candidate resume", 0.4)).rejects.toThrow( + "AI Gateway API error (401). Check the gateway key and model access.", + ); + expect(text).not.toHaveBeenCalled(); + }); + + it("does not return provider reasoning when answer content is missing", async () => { + fetchMock.mockResolvedValue(completion({ reasoning_content: "Private reasoning" })); + expect(await generateGatewayContent("Prompt", 0.4)).toBe(""); + }); + + it("aborts requests using the configured timeout", async () => { + vi.stubEnv("AI_TIMEOUT_MS", "12000"); + const signal = new AbortController().signal; + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(signal); + fetchMock.mockResolvedValue(completion({ content: "Answer" })); + await generateGatewayContent("Prompt", 0.4); + expect(timeout).toHaveBeenCalledWith(12000); + expect(fetchMock.mock.calls[0][1].signal).toBe(signal); + }); + + it.each(["not-a-number", "0", "-1", "1.5", "4294967296"])( + "uses a bounded default for an invalid timeout (%s)", + async (value) => { + vi.stubEnv("AI_TIMEOUT_MS", value); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(new AbortController().signal); + fetchMock.mockResolvedValue(completion({ content: "Answer" })); + await generateGatewayContent("Prompt", 0.4); + expect(timeout).toHaveBeenCalledWith(30000); + }, + ); + + it("preserves caller cancellation instead of replacing its signal", async () => { + const controller = new AbortController(); + controller.abort(); + const error = new DOMException("Aborted", "AbortError"); + fetchMock.mockRejectedValue(error); + await expect(generateGatewayContent("Prompt", 0.4, controller.signal)).rejects.toBe(error); + expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/lib/gemini.test.ts b/test/lib/gemini.test.ts index c001176..7106bcc 100644 --- a/test/lib/gemini.test.ts +++ b/test/lib/gemini.test.ts @@ -7,7 +7,7 @@ import { } from "@/lib/gemini"; /** - * The module calls OpenCode Zen's OpenAI-compatible `POST /chat/completions` + * The module calls AI Gateway's OpenAI-compatible `POST /chat/completions` * with plain `fetch`, so we stub `fetch` rather than a provider SDK. */ function mockCompletion(content: string) { @@ -53,7 +53,7 @@ describe("gemini", () => { await expect(generateInterviewQuestions("Resume", "Job", "Engineer")).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("should call OpenCode Zen with correct parameters for overview", async () => { + it("should call AI Gateway with correct parameters for overview", async () => { fetchMock.mockResolvedValue(mockCompletion("Analysis result")); const result = await analyzeResume("resume test", "job test", "overview"); @@ -63,11 +63,11 @@ describe("gemini", () => { const [url, init] = fetchMock.mock.calls[0]; expect(url).toContain("/chat/completions"); expect((init as RequestInit).method).toBe("POST"); - expect(lastRequestBody().model).toBe(process.env.MODEL_NAME || "big-pickle"); + expect(lastRequestBody().model).toBe(process.env.MODEL_NAME || "claude-sonnet-5"); }); it("sends an output budget large enough to survive reasoning tokens", async () => { - // big-pickle charges reasoning tokens against max_tokens; small budgets + // Reasoning models may charge reasoning tokens against max_tokens; small budgets // are consumed entirely by reasoning and return empty content. fetchMock.mockResolvedValue(mockCompletion("Analysis result")); diff --git a/test/lib/health-check.test.ts b/test/lib/health-check.test.ts index 5db4df0..43de383 100644 --- a/test/lib/health-check.test.ts +++ b/test/lib/health-check.test.ts @@ -12,13 +12,34 @@ afterEach(() => { }); describe("AI health probe", () => { it("reports an HTTP rejection as degraded", async () => { - vi.stubEnv("OPENCODE_API_KEY", "test-key"); + vi.stubEnv("AI_GATEWAY_API_KEY", "test-key"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Denied", { status: 401 }))); expect((await runDependencyHealthChecks()).ai.status).toBe("degraded"); }); it("reports a successful probe as healthy", async () => { - vi.stubEnv("OPENCODE_API_KEY", "test-key"); + vi.stubEnv("AI_GATEWAY_API_KEY", "test-key"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 }))); expect((await runDependencyHealthChecks()).ai.status).toBe("ok"); }); + it("uses the gateway models endpoint and the same managed key", async () => { + vi.stubEnv("AI_GATEWAY_API_KEY", "sk-managed-test"); + vi.stubEnv("AI_GATEWAY_BASE_URL", "https://gateway.example/v1/"); + const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + expect((await runDependencyHealthChecks()).ai.status).toBe("ok"); + expect(fetchMock).toHaveBeenCalledWith( + "https://gateway.example/v1/models", + expect.objectContaining({ + headers: { Authorization: "Bearer sk-managed-test" }, + signal: expect.any(AbortSignal), + }), + ); + }); + it("reports a missing key without making a probe", async () => { + vi.stubEnv("AI_GATEWAY_API_KEY", ""); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + expect((await runDependencyHealthChecks()).ai.status).toBe("missing"); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/test/setup.ts b/test/setup.ts index 81382d4..18f5002 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ import "@testing-library/jest-dom/vitest"; -process.env.OPENCODE_API_KEY ||= "test-opencode-api-key"; +process.env.AI_GATEWAY_API_KEY ||= "test-gateway-api-key"; process.env.NEXT_PUBLIC_CONVEX_URL ||= "https://test-project.convex.cloud";