- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions.
+ Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
)}
diff --git a/app/src/components/H2SelectionList.test.tsx b/app/src/components/H2SelectionList.test.tsx
index 6a21a81..1baa202 100644
--- a/app/src/components/H2SelectionList.test.tsx
+++ b/app/src/components/H2SelectionList.test.tsx
@@ -104,7 +104,7 @@ describe("H2SelectionList", () => {
it("disables all per-item regenerate buttons", () => {
render();
- const regenerateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options");
+ const regenerateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options");
for (const btn of regenerateButtons) {
expect(btn).toBeDisabled();
}
@@ -113,7 +113,7 @@ describe("H2SelectionList", () => {
it("shows a message about setting up the API key", () => {
render();
expect(
- screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."),
+ screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."),
).toBeInTheDocument();
});
diff --git a/app/src/components/H2SelectionList.tsx b/app/src/components/H2SelectionList.tsx
index b4021c1..92e8aad 100644
--- a/app/src/components/H2SelectionList.tsx
+++ b/app/src/components/H2SelectionList.tsx
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { Copy, RefreshCw, Check, Sparkles } from "lucide-react";
import { Button } from "./ui/Button";
import { cn } from "@/lib/utils";
+import { aiErrorMessage } from "@/lib/ai-error";
interface H2Item {
index: number;
@@ -76,8 +77,8 @@ export function H2SelectionList({
const result = await onRegenerateOne(index, h2Text);
setSuggestions((prev) => ({ ...prev, [index]: result }));
onToast("H2 suggestion regenerated");
- } catch {
- onToast("Failed to regenerate");
+ } catch (err) {
+ onToast(aiErrorMessage(err, "Failed to regenerate"));
} finally {
setLoadingItems((prev) => {
const next = new Set(prev);
@@ -99,8 +100,8 @@ export function H2SelectionList({
});
setSuggestions(newSuggestions);
onToast("All H2 suggestions generated");
- } catch {
- onToast("Failed to generate suggestions");
+ } catch (err) {
+ onToast(aiErrorMessage(err, "Failed to generate suggestions"));
} finally {
setLoadingAll(false);
}
@@ -136,7 +137,7 @@ export function H2SelectionList({
onClick={handleRegenerateAll}
loading={loadingAll}
disabled={aiDisabled}
- title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : undefined}
+ title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : undefined}
>
Generate All
@@ -145,7 +146,7 @@ export function H2SelectionList({
{aiDisabled && (
- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions.
+ Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
)}
@@ -196,7 +197,7 @@ export function H2SelectionList({
onClick={() => handleRegenerateOne(item.index, item.text)}
disabled={isLoading || aiDisabled}
className="rounded-full p-1.5 text-muted hover:bg-surface-2 hover:text-ink transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted"
- title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Regenerate"}
+ title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Regenerate"}
>
{
describe("when aiDisabled is true", () => {
it("disables all generate buttons", () => {
render();
- const generateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options");
+ const generateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options");
for (const btn of generateButtons) {
expect(btn).toBeDisabled();
}
@@ -96,7 +96,7 @@ describe("ImageAltTextList", () => {
it("shows a message about setting up the API key", () => {
render();
expect(
- screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."),
+ screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."),
).toBeInTheDocument();
});
@@ -106,7 +106,7 @@ describe("ImageAltTextList", () => {
render(
,
);
- const generateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options");
+ const generateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options");
await user.click(generateButtons[0]);
expect(onGenerate).not.toHaveBeenCalled();
});
diff --git a/app/src/components/ImageAltTextList.tsx b/app/src/components/ImageAltTextList.tsx
index 4ef9326..9f39576 100644
--- a/app/src/components/ImageAltTextList.tsx
+++ b/app/src/components/ImageAltTextList.tsx
@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { Copy, RefreshCw, Check, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
+import { aiErrorMessage } from "@/lib/ai-error";
import type { ImageData } from "@/types/seo";
interface ImageAltTextListProps {
@@ -29,8 +30,8 @@ export function ImageAltTextList({
const result = await onGenerate(src);
setAltTexts((prev) => ({ ...prev, [index]: result }));
onToast("Alt text generated");
- } catch {
- onToast("Failed to generate alt text");
+ } catch (err) {
+ onToast(aiErrorMessage(err, "Failed to generate alt text"));
} finally {
setLoadingItems((prev) => {
const next = new Set(prev);
@@ -70,7 +71,7 @@ export function ImageAltTextList({
{aiDisabled && (
- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions.
+ Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
)}
@@ -124,7 +125,7 @@ export function ImageAltTextList({
onClick={() => handleGenerate(index, img.src)}
disabled={isLoading || aiDisabled}
className="rounded-full p-1.5 text-muted hover:bg-surface-2 hover:text-ink transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted"
- title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Generate alt text"}
+ title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Generate alt text"}
>
{isLoading ? (
diff --git a/app/src/components/RecommendationBox.test.tsx b/app/src/components/RecommendationBox.test.tsx
index e683dcf..b42d8bc 100644
--- a/app/src/components/RecommendationBox.test.tsx
+++ b/app/src/components/RecommendationBox.test.tsx
@@ -61,14 +61,14 @@ describe("RecommendationBox", () => {
describe("when aiDisabled is true", () => {
it("disables the regenerate button", () => {
render();
- const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options");
+ const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options");
expect(btn).toBeDisabled();
});
it("shows a message about setting up the API key", () => {
render();
expect(
- screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."),
+ screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."),
).toBeInTheDocument();
});
@@ -78,7 +78,7 @@ describe("RecommendationBox", () => {
render(
,
);
- const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options");
+ const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options");
await user.click(btn);
expect(onRegenerate).not.toHaveBeenCalled();
});
diff --git a/app/src/components/RecommendationBox.tsx b/app/src/components/RecommendationBox.tsx
index 185037d..dfb29bd 100644
--- a/app/src/components/RecommendationBox.tsx
+++ b/app/src/components/RecommendationBox.tsx
@@ -61,7 +61,7 @@ export function RecommendationBox({
onClick={handleRegenerate}
disabled={loading || aiDisabled}
className="rounded-full p-1.5 text-muted transition-colors hover:bg-surface-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted"
- title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Regenerate"}
+ title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Regenerate"}
aria-label="Regenerate"
>
@@ -71,7 +71,7 @@ export function RecommendationBox({
{text}
{aiDisabled && (
- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions.
+ Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
)}
diff --git a/app/src/lib/ai-error.ts b/app/src/lib/ai-error.ts
new file mode 100644
index 0000000..c8efa1d
--- /dev/null
+++ b/app/src/lib/ai-error.ts
@@ -0,0 +1,14 @@
+// Maps AI errors to user-facing toast text. Proxy quota/unavailable errors
+// carry friendly, actionable messages worth surfacing verbatim; anything else
+// falls back to a generic message. Kept provider-agnostic (matches by name) so
+// UI components don't import the AI modules.
+export function aiErrorMessage(error: unknown, fallback: string): string {
+ if (
+ error instanceof Error &&
+ (error.name === "AiUnavailableError" || error.name === "AiProxyError") &&
+ error.message
+ ) {
+ return error.message;
+ }
+ return fallback;
+}
diff --git a/app/src/lib/ai-proxy.test.ts b/app/src/lib/ai-proxy.test.ts
new file mode 100644
index 0000000..e398180
--- /dev/null
+++ b/app/src/lib/ai-proxy.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { generateViaProxy, AiProxyError, type ProxyRequest } from "@/lib/ai-proxy";
+import { BACKEND_BASE_URL } from "@/lib/entitlement-keys";
+import { getInstallId, getRawEntitlementToken } from "@/lib/entitlement";
+
+// Mock only the two entitlement helpers ai-proxy depends on, so this suite drives
+// the proxy request/response mapping without touching jose or storage.
+vi.mock("@/lib/entitlement", () => ({
+ getInstallId: vi.fn(),
+ getRawEntitlementToken: vi.fn(),
+}));
+
+const getInstallIdMock = vi.mocked(getInstallId);
+const getRawEntitlementTokenMock = vi.mocked(getRawEntitlementToken);
+
+const GENERATE_URL = `${BACKEND_BASE_URL}/ai/generate`;
+
+function jsonResponse(status: number, body: unknown, headers?: Record) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json", ...headers },
+ });
+}
+
+/** A 200 whose body is not valid JSON, exercising the `.json()` failure path. */
+function nonJsonResponse(status: number) {
+ return new Response("<>", {
+ status,
+ headers: { "Content-Type": "text/html" },
+ });
+}
+
+function request(overrides: Partial = {}): ProxyRequest {
+ return {
+ checkId: "check-1",
+ keyword: "seo keyword",
+ context: "the surrounding context",
+ authenticated: true,
+ ...overrides,
+ };
+}
+
+async function expectProxyError(
+ promise: Promise,
+ code: AiProxyError["code"],
+): Promise {
+ const error = await promise.then(
+ () => null,
+ (e: unknown) => e,
+ );
+ expect(error).toBeInstanceOf(AiProxyError);
+ expect((error as AiProxyError).code).toBe(code);
+ return error as AiProxyError;
+}
+
+const OK_BODY = {
+ recommendation: "Add the keyword to the H1.",
+ model: "claude-x",
+ quota: { limit: 100, remaining: 99, period: "2026-07" },
+};
+
+beforeEach(() => {
+ getInstallIdMock.mockResolvedValue("install-xyz");
+ getRawEntitlementTokenMock.mockResolvedValue("tok-123");
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("generateViaProxy request shape", () => {
+ it("POSTs to /ai/generate with the check payload and the resolved install id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await generateViaProxy(
+ request({ checkId: "c9", keyword: "kw", context: "ctx", authenticated: true }),
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(GENERATE_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Optia-Entitlement": "tok-123" },
+ body: JSON.stringify({
+ checkId: "c9",
+ keyword: "kw",
+ context: "ctx",
+ installId: "install-xyz",
+ }),
+ });
+ });
+
+ it("attaches X-Optia-Entitlement when authenticated and a token is available", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const result = await generateViaProxy(request({ authenticated: true }));
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect((init.headers as Record)["X-Optia-Entitlement"]).toBe("tok-123");
+ // The subject actually metered was the entitlement (Pro).
+ expect(result.authenticated).toBe(true);
+ });
+
+ it("reports authenticated:false when authenticated but no token is stored (install-metered)", async () => {
+ getRawEntitlementTokenMock.mockResolvedValue(null);
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const result = await generateViaProxy(request({ authenticated: true }));
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
+ expect(getRawEntitlementTokenMock).toHaveBeenCalledTimes(1);
+ // A Pro request whose token is missing was metered as free, and says so.
+ expect(result.authenticated).toBe(false);
+ });
+
+ it("omits X-Optia-Entitlement and never reads the token when not authenticated", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await generateViaProxy(request({ authenticated: false }));
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
+ expect(getRawEntitlementTokenMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("generateViaProxy success", () => {
+ it("returns the recommendation, model, and quota from a 200 body", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY)));
+
+ const result = await generateViaProxy(request());
+
+ expect(result).toMatchObject(OK_BODY);
+ expect(result.quota).toEqual({ limit: 100, remaining: 99, period: "2026-07" });
+ });
+});
+
+describe("generateViaProxy error mapping", () => {
+ it("maps a 429 with a QUOTA_EXCEEDED code (nested error body) to quota_exceeded", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ jsonResponse(429, { error: { code: "QUOTA_EXCEEDED", message: "You are out of AI credits." } }),
+ ),
+ );
+
+ const error = await expectProxyError(generateViaProxy(request()), "quota_exceeded");
+ expect(error.message).toBe("You are out of AI credits.");
+ });
+
+ it("maps a 429 with a QUOTA_EXCEEDED code (bare code/message body) to quota_exceeded", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ jsonResponse(429, { code: "QUOTA_EXCEEDED", message: "Monthly limit hit." }),
+ ),
+ );
+
+ const error = await expectProxyError(generateViaProxy(request()), "quota_exceeded");
+ expect(error.message).toBe("Monthly limit hit.");
+ });
+
+ it("maps a 429 without a quota code to rate_limited", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(jsonResponse(429, { error: { code: "TOO_FAST" } })),
+ );
+
+ await expectProxyError(generateViaProxy(request()), "rate_limited");
+ });
+
+ it("maps a 401 to unauthorized", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(jsonResponse(401, { error: { message: "Entitlement expired." } })),
+ );
+
+ const error = await expectProxyError(generateViaProxy(request()), "unauthorized");
+ expect(error.message).toBe("Entitlement expired.");
+ });
+
+ it("maps a 400 to invalid using a bare message body", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(jsonResponse(400, { code: "BAD_INPUT", message: "Keyword required." })),
+ );
+
+ const error = await expectProxyError(generateViaProxy(request()), "invalid");
+ expect(error.message).toBe("Keyword required.");
+ });
+
+ it("maps any other non-ok status to upstream", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(500, {})));
+
+ const error = await expectProxyError(generateViaProxy(request()), "upstream");
+ expect(error.message).toBe("The AI provider could not complete the request.");
+ });
+
+ it("falls back to a default message when the error body is not JSON", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(nonJsonResponse(503)));
+
+ const error = await expectProxyError(generateViaProxy(request()), "upstream");
+ expect(error.message).toBe("The AI provider could not complete the request.");
+ });
+
+ it("maps a fetch rejection to a network error", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
+
+ const error = await expectProxyError(generateViaProxy(request()), "network");
+ expect(error.message).toBe("Could not reach the AI service.");
+ });
+});
+
+describe("generateViaProxy malformed success bodies", () => {
+ it("treats a 200 missing recommendation as upstream", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ jsonResponse(200, { model: "claude-x", quota: OK_BODY.quota }),
+ ),
+ );
+
+ await expectProxyError(generateViaProxy(request()), "upstream");
+ });
+
+ it("treats a 200 whose recommendation is not a string as upstream", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ jsonResponse(200, { recommendation: 42, model: "claude-x", quota: OK_BODY.quota }),
+ ),
+ );
+
+ await expectProxyError(generateViaProxy(request()), "upstream");
+ });
+
+ it("treats a 200 missing quota as upstream", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ jsonResponse(200, { recommendation: "ok", model: "claude-x" }),
+ ),
+ );
+
+ const error = await expectProxyError(generateViaProxy(request()), "upstream");
+ expect(error.message).toBe("The AI service returned an unexpected response.");
+ });
+
+ it("treats a 200 with an unparseable body as upstream", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(nonJsonResponse(200)));
+
+ await expectProxyError(generateViaProxy(request()), "upstream");
+ });
+});
diff --git a/app/src/lib/ai-proxy.ts b/app/src/lib/ai-proxy.ts
new file mode 100644
index 0000000..a33e47a
--- /dev/null
+++ b/app/src/lib/ai-proxy.ts
@@ -0,0 +1,125 @@
+import { BACKEND_BASE_URL } from "@/lib/entitlement-keys";
+import { getInstallId, getRawEntitlementToken } from "@/lib/entitlement";
+
+// Hosted, entitlement-gated proxy to Claude. Used for free-tier generation
+// (metered by install id) and Pro-without-key generation (metered by the signed
+// entitlement, sent as X-Optia-Entitlement). BYO keys never come here — those go
+// browser→Anthropic directly (see anthropic.ts).
+
+export type AiProxyErrorCode =
+ | "quota_exceeded"
+ | "rate_limited"
+ | "unauthorized"
+ | "invalid"
+ | "upstream"
+ | "network";
+
+export class AiProxyError extends Error {
+ constructor(
+ public code: AiProxyErrorCode,
+ message: string,
+ ) {
+ super(message);
+ this.name = "AiProxyError";
+ }
+}
+
+export interface ProxyQuota {
+ limit: number;
+ remaining: number;
+ period: string;
+}
+
+export interface ProxyResult {
+ recommendation: string;
+ model: string;
+ quota: ProxyQuota;
+ /** Whether the entitlement was actually presented (drives which quota the caller records). */
+ authenticated: boolean;
+}
+
+export interface ProxyRequest {
+ checkId: string;
+ keyword: string;
+ context: string;
+ /** When true, authenticate as Pro via the stored entitlement; else free-tier by install id. */
+ authenticated: boolean;
+}
+
+interface ErrorBody {
+ error?: { code?: string; message?: string };
+ code?: string;
+ message?: string;
+}
+
+async function readError(response: Response): Promise<{ code: string; message: string }> {
+ try {
+ const body = (await response.json()) as ErrorBody;
+ return {
+ code: body.error?.code ?? body.code ?? "",
+ message: body.error?.message ?? body.message ?? "",
+ };
+ } catch {
+ return { code: "", message: "" };
+ }
+}
+
+function mapError(status: number, code: string, message: string): AiProxyError {
+ if (status === 429) {
+ if (code === "QUOTA_EXCEEDED") {
+ return new AiProxyError("quota_exceeded", message || "AI quota reached.");
+ }
+ return new AiProxyError("rate_limited", message || "Too many AI requests. Please slow down.");
+ }
+ if (status === 401) {
+ return new AiProxyError("unauthorized", message || "Your Pro entitlement is no longer valid.");
+ }
+ if (status === 400) {
+ return new AiProxyError("invalid", message || "The AI request was rejected.");
+ }
+ return new AiProxyError("upstream", message || "The AI provider could not complete the request.");
+}
+
+/** POSTs one generation to the hosted proxy. Throws AiProxyError on failure. */
+export async function generateViaProxy(request: ProxyRequest): Promise {
+ const { checkId, keyword, context, authenticated } = request;
+ const headers: Record = { "Content-Type": "application/json" };
+ // Pro metering requires the token to actually be present; if it isn't, the
+ // request is install-metered (free) and the caller records it as such.
+ let didAuthenticate = false;
+ if (authenticated) {
+ const token = await getRawEntitlementToken();
+ if (token) {
+ headers["X-Optia-Entitlement"] = token;
+ didAuthenticate = true;
+ }
+ }
+ const installId = await getInstallId();
+
+ let response: Response;
+ try {
+ response = await fetch(`${BACKEND_BASE_URL}/ai/generate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ checkId, keyword, context, installId }),
+ });
+ } catch {
+ throw new AiProxyError("network", "Could not reach the AI service.");
+ }
+
+ if (!response.ok) {
+ const { code, message } = await readError(response);
+ throw mapError(response.status, code, message);
+ }
+
+ const body = (await response.json().catch(() => null)) as Partial | null;
+ if (!body || typeof body.recommendation !== "string" || !body.quota) {
+ throw new AiProxyError("upstream", "The AI service returned an unexpected response.");
+ }
+ return {
+ recommendation: body.recommendation,
+ model: body.model ?? "",
+ quota: body.quota,
+ authenticated: didAuthenticate,
+ };
+}
diff --git a/app/src/lib/ai.test.ts b/app/src/lib/ai.test.ts
new file mode 100644
index 0000000..56e6905
--- /dev/null
+++ b/app/src/lib/ai.test.ts
@@ -0,0 +1,316 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ generateRecommendation,
+ generateH2Suggestion,
+ generateAllH2Suggestions,
+ generateAltText,
+ AiUnavailableError,
+ type AdvancedOptions,
+} from "@/lib/ai";
+import {
+ generateRecommendationDirect,
+ generateH2SuggestionDirect,
+ generateAltTextDirect,
+} from "@/lib/anthropic";
+import { generateViaProxy, AiProxyError } from "@/lib/ai-proxy";
+import { aiStatusNow, useEntitlementStore, type AiStatus } from "@/lib/entitlement-store";
+import { useStore } from "@/lib/store";
+
+// The AI facade only routes; the three access paths (direct SDK, hosted proxy)
+// and the entitlement gate are all mocked so these tests assert *dispatch* —
+// which downstream fn is called, with what args — not the generation itself.
+vi.mock("@/lib/anthropic", () => ({
+ generateRecommendationDirect: vi.fn(),
+ generateH2SuggestionDirect: vi.fn(),
+ generateAltTextDirect: vi.fn(),
+}));
+
+// Keep the real AiProxyError class (ai.ts does `err instanceof AiProxyError`);
+// only the network call is mocked.
+vi.mock("@/lib/ai-proxy", async (importOriginal) => {
+ const original = await importOriginal();
+ return { ...original, generateViaProxy: vi.fn() };
+});
+
+// Keep the real store instance (so applyProxyQuota can be swapped via setState)
+// but take control of the entitlement decision by mocking aiStatusNow.
+vi.mock("@/lib/entitlement-store", async (importOriginal) => {
+ const original = await importOriginal();
+ return { ...original, aiStatusNow: vi.fn() };
+});
+
+const generateRecommendationDirectMock = vi.mocked(generateRecommendationDirect);
+const generateH2SuggestionDirectMock = vi.mocked(generateH2SuggestionDirect);
+const generateAltTextDirectMock = vi.mocked(generateAltTextDirect);
+const generateViaProxyMock = vi.mocked(generateViaProxy);
+const aiStatusNowMock = vi.mocked(aiStatusNow);
+
+const TEST_KEY = "sk-ant-test-key";
+const testQuota = { period: "2026-07", remaining: 42, limit: 100 };
+const advancedOptions: AdvancedOptions = {
+ pageType: "product-page",
+ secondaryKeywords: "seo, ranking",
+ languageCode: "fr",
+};
+
+// applyProxyQuota is a store action; we swap it for a spy each test so we can
+// assert the recorded (quota, subject) without touching real storage.
+let applyProxyQuotaMock: ReturnType;
+
+function setMode(mode: AiStatus["mode"]): void {
+ aiStatusNowMock.mockReturnValue({ mode, remaining: null, limit: null });
+}
+
+beforeEach(() => {
+ applyProxyQuotaMock = vi.fn().mockResolvedValue(undefined);
+ useEntitlementStore.setState({ applyProxyQuota: applyProxyQuotaMock });
+ useStore.setState({ apiKey: "" });
+
+ generateRecommendationDirectMock.mockResolvedValue("direct recommendation");
+ generateH2SuggestionDirectMock.mockResolvedValue("direct h2");
+ generateAltTextDirectMock.mockResolvedValue("direct alt");
+ // The proxy reports the subject it actually metered against; mirror the
+ // request's `authenticated` flag (a Pro user in these tests has a token).
+ generateViaProxyMock.mockImplementation(async (req) => ({
+ recommendation: "proxy recommendation",
+ model: "claude-opus-4-8",
+ quota: testQuota,
+ authenticated: req.authenticated,
+ }));
+});
+
+describe("generateRecommendation", () => {
+ it("byok → calls the Direct fn with the stored key + advancedOptions, never the proxy", async () => {
+ setMode("byok");
+ useStore.setState({ apiKey: TEST_KEY });
+ generateRecommendationDirectMock.mockResolvedValue("byok rec");
+
+ const result = await generateRecommendation("title-keyword", "kw", "current title", advancedOptions);
+
+ expect(result).toBe("byok rec");
+ expect(generateRecommendationDirectMock).toHaveBeenCalledWith(
+ TEST_KEY,
+ "title-keyword",
+ "kw",
+ "current title",
+ advancedOptions,
+ );
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ expect(applyProxyQuotaMock).not.toHaveBeenCalled();
+ });
+
+ it("pro → routes to the proxy authenticated, records the quota as 'pro', returns the recommendation", async () => {
+ setMode("pro");
+
+ const result = await generateRecommendation("title-keyword", "kw", "current title", advancedOptions);
+
+ expect(result).toBe("proxy recommendation");
+ expect(generateViaProxyMock).toHaveBeenCalledWith({
+ checkId: "title-keyword",
+ keyword: "kw",
+ context: "current title",
+ authenticated: true,
+ });
+ // advancedOptions are a BYO-key enhancement and must NOT reach the proxy.
+ expect(generateViaProxyMock).toHaveBeenCalledTimes(1);
+ expect(generateViaProxyMock.mock.calls[0][0]).not.toHaveProperty("advancedOptions");
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "pro");
+ expect(generateRecommendationDirectMock).not.toHaveBeenCalled();
+ });
+
+ it("free → routes to the proxy unauthenticated and records the quota as 'free'", async () => {
+ setMode("free");
+
+ const result = await generateRecommendation("meta-description-keyword", "kw", "desc");
+
+ expect(result).toBe("proxy recommendation");
+ expect(generateViaProxyMock).toHaveBeenCalledWith({
+ checkId: "meta-description-keyword",
+ keyword: "kw",
+ context: "desc",
+ authenticated: false,
+ });
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free");
+ expect(generateRecommendationDirectMock).not.toHaveBeenCalled();
+ });
+
+ it("locked → throws AiUnavailableError and calls nothing downstream", async () => {
+ setMode("locked");
+
+ const error = await generateRecommendation("title-keyword", "kw", "ctx").then(
+ () => null,
+ (e: unknown) => e,
+ );
+
+ expect(error).toBeInstanceOf(AiUnavailableError);
+ expect((error as Error).name).toBe("AiUnavailableError");
+ expect(generateRecommendationDirectMock).not.toHaveBeenCalled();
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ expect(applyProxyQuotaMock).not.toHaveBeenCalled();
+ });
+
+ it("free → a server quota_exceeded drives cached remaining to 0 and re-throws", async () => {
+ setMode("free");
+ useEntitlementStore.setState({ freeAiLimit: 10 });
+ generateViaProxyMock.mockRejectedValue(new AiProxyError("quota_exceeded", "quota reached"));
+
+ const error = await generateRecommendation("title-keyword", "kw", "ctx").then(
+ () => null,
+ (e: unknown) => e,
+ );
+
+ // Re-thrown so the UI surfaces the friendly message...
+ expect(error).toBeInstanceOf(AiProxyError);
+ // ...and the cache is driven to 0 so the gate converges to locked/upsell.
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(
+ expect.objectContaining({ remaining: 0, limit: 10 }),
+ "free",
+ );
+ });
+
+ it("pro → records against the subject the proxy actually metered (free if no token)", async () => {
+ setMode("pro");
+ // A Pro request whose token was missing is install-metered by the server.
+ generateViaProxyMock.mockResolvedValue({
+ recommendation: "r",
+ model: "m",
+ quota: testQuota,
+ authenticated: false,
+ });
+
+ await generateRecommendation("title-keyword", "kw", "ctx");
+
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free");
+ });
+});
+
+describe("generateH2Suggestion", () => {
+ it("byok → calls generateH2SuggestionDirect with the key, h2 text, keyword, and advancedOptions", async () => {
+ setMode("byok");
+ useStore.setState({ apiKey: TEST_KEY });
+ generateH2SuggestionDirectMock.mockResolvedValue("byok h2");
+
+ const result = await generateH2Suggestion("Current Heading", "kw", advancedOptions);
+
+ expect(result).toBe("byok h2");
+ expect(generateH2SuggestionDirectMock).toHaveBeenCalledWith(
+ TEST_KEY,
+ "Current Heading",
+ "kw",
+ advancedOptions,
+ );
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ });
+
+ it("proxy path → maps to checkId 'h2-keyword' with the h2 text as context", async () => {
+ setMode("pro");
+
+ const result = await generateH2Suggestion("Current Heading", "kw", advancedOptions);
+
+ expect(result).toBe("proxy recommendation");
+ expect(generateViaProxyMock).toHaveBeenCalledWith({
+ checkId: "h2-keyword",
+ keyword: "kw",
+ context: "Current Heading",
+ authenticated: true,
+ });
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "pro");
+ });
+
+ it("locked → throws AiUnavailableError", async () => {
+ setMode("locked");
+
+ const error = await generateH2Suggestion("Heading", "kw").then(
+ () => null,
+ (e: unknown) => e,
+ );
+
+ expect((error as Error).name).toBe("AiUnavailableError");
+ expect(generateH2SuggestionDirectMock).not.toHaveBeenCalled();
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("generateAllH2Suggestions", () => {
+ it("byok → returns an array, one Direct-generated suggestion per input heading", async () => {
+ setMode("byok");
+ useStore.setState({ apiKey: TEST_KEY });
+ generateH2SuggestionDirectMock.mockImplementation(async (_key, h2Text) => `optimized: ${h2Text}`);
+
+ const result = await generateAllH2Suggestions(["Intro", "Features", "Pricing"], "kw", advancedOptions);
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toEqual(["optimized: Intro", "optimized: Features", "optimized: Pricing"]);
+ expect(generateH2SuggestionDirectMock).toHaveBeenCalledTimes(3);
+ expect(generateH2SuggestionDirectMock).toHaveBeenNthCalledWith(1, TEST_KEY, "Intro", "kw", advancedOptions);
+ });
+
+ it("proxy path → returns an array and records quota once per heading", async () => {
+ setMode("free");
+
+ const result = await generateAllH2Suggestions(["A", "B"], "kw");
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toEqual(["proxy recommendation", "proxy recommendation"]);
+ expect(generateViaProxyMock).toHaveBeenCalledTimes(2);
+ expect(applyProxyQuotaMock).toHaveBeenCalledTimes(2);
+ expect(applyProxyQuotaMock).toHaveBeenLastCalledWith(testQuota, "free");
+ });
+
+ it("returns an empty array when given no headings (never routes)", async () => {
+ setMode("free");
+
+ const result = await generateAllH2Suggestions([], "kw");
+
+ expect(result).toEqual([]);
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ expect(generateH2SuggestionDirectMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("generateAltText", () => {
+ it("byok → calls generateAltTextDirect with the key, image src, keyword, and advancedOptions", async () => {
+ setMode("byok");
+ useStore.setState({ apiKey: TEST_KEY });
+ generateAltTextDirectMock.mockResolvedValue("byok alt");
+
+ const result = await generateAltText("https://cdn.example.com/pic.png", "kw", advancedOptions);
+
+ expect(result).toBe("byok alt");
+ expect(generateAltTextDirectMock).toHaveBeenCalledWith(
+ TEST_KEY,
+ "https://cdn.example.com/pic.png",
+ "kw",
+ advancedOptions,
+ );
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ });
+
+ it("proxy path → maps to checkId 'images-alt' with the image src as context", async () => {
+ setMode("free");
+
+ const result = await generateAltText("https://cdn.example.com/pic.png", "kw");
+
+ expect(result).toBe("proxy recommendation");
+ expect(generateViaProxyMock).toHaveBeenCalledWith({
+ checkId: "images-alt",
+ keyword: "kw",
+ context: "https://cdn.example.com/pic.png",
+ authenticated: false,
+ });
+ expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free");
+ });
+
+ it("locked → throws AiUnavailableError", async () => {
+ setMode("locked");
+
+ const error = await generateAltText("https://cdn.example.com/pic.png", "kw").then(
+ () => null,
+ (e: unknown) => e,
+ );
+
+ expect((error as Error).name).toBe("AiUnavailableError");
+ expect(generateAltTextDirectMock).not.toHaveBeenCalled();
+ expect(generateViaProxyMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/src/lib/ai.ts b/app/src/lib/ai.ts
new file mode 100644
index 0000000..57f199d
--- /dev/null
+++ b/app/src/lib/ai.ts
@@ -0,0 +1,120 @@
+import { useStore } from "@/lib/store";
+import { aiStatusNow, useEntitlementStore } from "@/lib/entitlement-store";
+import { currentAiPeriod } from "@/lib/entitlement";
+import {
+ generateRecommendationDirect,
+ generateH2SuggestionDirect,
+ generateAltTextDirect,
+ type AdvancedOptions,
+} from "@/lib/anthropic";
+import { generateViaProxy, AiProxyError } from "@/lib/ai-proxy";
+
+export type { AdvancedOptions };
+
+// AI facade: routes each generation to the right path based on entitlement.
+// byok → direct browser→Anthropic call with the user's key (advanced context applies)
+// pro → hosted proxy authenticated by entitlement (higher monthly cap)
+// free → hosted proxy metered by install id (capped monthly allowance)
+// locked → no path available → AiUnavailableError (surface a friendly upsell)
+//
+// The proxy accepts only { checkId, keyword, context }; advanced options are a
+// BYO-key enhancement and are not forwarded to it.
+
+export class AiUnavailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AiUnavailableError";
+ }
+}
+
+/** Locked-state message tailored to the tier so it never dead-ends the user. */
+function lockedError(): AiUnavailableError {
+ const isPro = useEntitlementStore.getState().isPro;
+ return new AiUnavailableError(
+ isPro
+ ? "You've reached your monthly AI limit. Add your own Anthropic key in options for unlimited AI."
+ : "You've used your free AI recommendations for this month. Upgrade to Optia Pro for more.",
+ );
+}
+
+async function runProxy(
+ checkId: string,
+ keyword: string,
+ context: string,
+ authenticated: boolean,
+): Promise {
+ try {
+ const result = await generateViaProxy({ checkId, keyword, context, authenticated });
+ // Record against the subject the server actually metered (a Pro request with
+ // no token falls back to install metering).
+ await useEntitlementStore
+ .getState()
+ .applyProxyQuota(result.quota, result.authenticated ? "pro" : "free");
+ return result.recommendation;
+ } catch (err) {
+ // The server is the quota authority: on a quota rejection, drive the cached
+ // remaining to 0 so the UI converges to the locked/upsell state instead of
+ // looping on error toasts with the controls still enabled.
+ if (err instanceof AiProxyError && err.code === "quota_exceeded") {
+ const state = useEntitlementStore.getState();
+ const subject = authenticated ? "pro" : "free";
+ const limit = subject === "pro" ? state.quotaLimit : (state.freeAiLimit ?? 0);
+ await state.applyProxyQuota({ period: currentAiPeriod(), remaining: 0, limit }, subject);
+ }
+ throw err;
+ }
+}
+
+export async function generateRecommendation(
+ checkId: string,
+ keyword: string,
+ context: string,
+ advancedOptions?: AdvancedOptions,
+): Promise {
+ const status = aiStatusNow();
+ if (status.mode === "locked") throw lockedError();
+ if (status.mode === "byok") {
+ return generateRecommendationDirect(
+ useStore.getState().apiKey,
+ checkId,
+ keyword,
+ context,
+ advancedOptions,
+ );
+ }
+ return runProxy(checkId, keyword, context, status.mode === "pro");
+}
+
+export async function generateH2Suggestion(
+ h2Text: string,
+ keyword: string,
+ advancedOptions?: AdvancedOptions,
+): Promise {
+ const status = aiStatusNow();
+ if (status.mode === "locked") throw lockedError();
+ if (status.mode === "byok") {
+ return generateH2SuggestionDirect(useStore.getState().apiKey, h2Text, keyword, advancedOptions);
+ }
+ return runProxy("h2-keyword", keyword, h2Text, status.mode === "pro");
+}
+
+export async function generateAllH2Suggestions(
+ h2Texts: string[],
+ keyword: string,
+ advancedOptions?: AdvancedOptions,
+): Promise {
+ return Promise.all(h2Texts.map((text) => generateH2Suggestion(text, keyword, advancedOptions)));
+}
+
+export async function generateAltText(
+ imageSrc: string,
+ keyword: string,
+ advancedOptions?: AdvancedOptions,
+): Promise {
+ const status = aiStatusNow();
+ if (status.mode === "locked") throw lockedError();
+ if (status.mode === "byok") {
+ return generateAltTextDirect(useStore.getState().apiKey, imageSrc, keyword, advancedOptions);
+ }
+ return runProxy("images-alt", keyword, imageSrc, status.mode === "pro");
+}
diff --git a/app/src/lib/anthropic.test.ts b/app/src/lib/anthropic.test.ts
new file mode 100644
index 0000000..a5f2e6f
--- /dev/null
+++ b/app/src/lib/anthropic.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ AI_MODEL,
+ generateRecommendationDirect,
+ generateH2SuggestionDirect,
+ generateAltTextDirect,
+} from "@/lib/anthropic";
+
+// A module-scoped mock for `client.messages.create`, driven per-test. Hoisted so
+// the vi.mock factory below (which is hoisted above imports) can close over it.
+const createMock = vi.hoisted(() => vi.fn());
+
+// Replace the SDK's default export with a constructor that hands back a client
+// whose messages.create is our controllable mock. The constructor args
+// (apiKey/dangerouslyAllowBrowser/baseURL) are irrelevant here since the network
+// is never touched.
+vi.mock("@anthropic-ai/sdk", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ messages: { create: createMock },
+ })),
+}));
+
+/** Build a mock Anthropic Message whose text block carries `text`. */
+function textMessage(text: string) {
+ return { content: [{ type: "text", text }] };
+}
+
+/** Grab the single argument object passed to the most recent create() call. */
+function lastCreateArgs() {
+ return createMock.mock.calls[createMock.mock.calls.length - 1][0] as {
+ model: string;
+ max_tokens: number;
+ system: string;
+ messages: { role: string; content: string }[];
+ };
+}
+
+beforeEach(() => {
+ createMock.mockReset();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("generateRecommendationDirect", () => {
+ it("returns the model text for a copyable check and strips wrapping quotes", async () => {
+ // Model wraps its answer in quotes the way it sometimes does.
+ createMock.mockResolvedValue(textMessage('"Best Running Shoes for Trail Runners"'));
+
+ const result = await generateRecommendationDirect(
+ "sk-test",
+ "title-keyword",
+ "running shoes",
+ "Old Title",
+ );
+
+ expect(result).toBe("Best Running Shoes for Trail Runners");
+
+ const args = lastCreateArgs();
+ expect(createMock).toHaveBeenCalledTimes(1);
+ // The copyable branch uses the "ready-to-use content" system prompt.
+ expect(args.system).toContain("ready-to-use content");
+ expect(args.messages[0].content).toContain('perfect SEO title for the keyphrase "running shoes"');
+ });
+
+ it("passes the Opus 4.8 model and the 1024 max_tokens budget", async () => {
+ createMock.mockResolvedValue(textMessage("Something"));
+
+ await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx");
+
+ expect(AI_MODEL).toBe("claude-opus-4-8");
+ const args = lastCreateArgs();
+ expect(args.model).toBe("claude-opus-4-8");
+ expect(args.max_tokens).toBe(1024);
+ expect(args.messages[0].role).toBe("user");
+ });
+
+ it("uses the advisory system prompt for a non-listed checkId", async () => {
+ createMock.mockResolvedValue(textMessage("Add descriptive internal links throughout the page."));
+
+ const result = await generateRecommendationDirect(
+ "sk-test",
+ "some-unknown-check",
+ "running shoes",
+ "current status here",
+ );
+
+ expect(result).toBe("Add descriptive internal links throughout the page.");
+
+ const args = lastCreateArgs();
+ // Default branch => advisory ("actionable advice") system prompt, not copyable.
+ expect(args.system).toContain("actionable advice");
+ expect(args.system).not.toContain("ready-to-use content");
+ expect(args.messages[0].content).toContain('Fix this SEO issue: "some-unknown-check"');
+ });
+
+ it("reads the first text block even when a non-text block precedes it", async () => {
+ createMock.mockResolvedValue({
+ content: [
+ { type: "thinking", thinking: "reasoning..." },
+ { type: "text", text: "The Final Title" },
+ ],
+ });
+
+ const result = await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx");
+
+ expect(result).toBe("The Final Title");
+ });
+});
+
+describe("generateH2SuggestionDirect", () => {
+ it("returns the generated H2 heading text", async () => {
+ createMock.mockResolvedValue(textMessage("Choosing the Best Running Shoes"));
+
+ const result = await generateH2SuggestionDirect(
+ "sk-test",
+ "Old Heading",
+ "running shoes",
+ );
+
+ expect(result).toBe("Choosing the Best Running Shoes");
+
+ const args = lastCreateArgs();
+ expect(args.model).toBe("claude-opus-4-8");
+ expect(args.system).toContain("H2 heading");
+ expect(args.messages[0].content).toContain('perfect H2 heading for the keyphrase "running shoes"');
+ expect(args.messages[0].content).toContain('Current H2: "Old Heading"');
+ });
+});
+
+describe("generateAltTextDirect", () => {
+ it("returns the alt text and derives the filename from the image URL", async () => {
+ createMock.mockResolvedValue(textMessage("Trail running shoes on a rocky path"));
+
+ const result = await generateAltTextDirect(
+ "sk-test",
+ "https://cdn.example.com/assets/trail-shoes.jpg?v=2",
+ "running shoes",
+ );
+
+ expect(result).toBe("Trail running shoes on a rocky path");
+
+ const args = lastCreateArgs();
+ expect(args.model).toBe("claude-opus-4-8");
+ expect(args.system).toContain("accessibility expert");
+ // Query string stripped, path removed: bare filename in the prompt.
+ expect(args.messages[0].content).toContain("Image filename: trail-shoes.jpg");
+ expect(args.messages[0].content).toContain(
+ "https://cdn.example.com/assets/trail-shoes.jpg?v=2",
+ );
+ });
+});
+
+describe("advancedOptions shape the prompt", () => {
+ it("injects language, page type, and secondary keyword context", async () => {
+ createMock.mockResolvedValue(textMessage("Meilleures Chaussures de Course"));
+
+ await generateRecommendationDirect(
+ "sk-test",
+ "title-keyword",
+ "chaussures de course",
+ "Ancien titre",
+ {
+ pageType: "product-page",
+ secondaryKeywords: "chaussures de trail, running",
+ languageCode: "fr",
+ },
+ );
+
+ const args = lastCreateArgs();
+ // French language instruction lives in the system prompt.
+ expect(args.system).toContain("Generate all content in French (Français)");
+ // Advanced context and the humanized page type live in the user prompt.
+ expect(args.messages[0].content).toContain("Advanced Context:");
+ expect(args.messages[0].content).toContain("Page Type: product-page");
+ expect(args.messages[0].content).toContain("Secondary Keywords: chaussures de trail, running");
+ expect(args.messages[0].content).toContain("for a product page");
+ });
+
+ it("adds no language instruction for English (the default)", async () => {
+ createMock.mockResolvedValue(textMessage("A Title"));
+
+ await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx", {
+ languageCode: "en",
+ });
+
+ const args = lastCreateArgs();
+ expect(args.system).not.toContain("Generate all content in");
+ expect(args.messages[0].content).not.toContain("Advanced Context:");
+ });
+});
+
+describe("completeWithRetry behavior", () => {
+ it("throws a 401 immediately without retrying", async () => {
+ createMock.mockRejectedValue(Object.assign(new Error("Unauthorized"), { status: 401 }));
+
+ await expect(
+ generateRecommendationDirect("sk-bad", "title-keyword", "kw", "ctx"),
+ ).rejects.toThrow("Unauthorized");
+
+ // Auth failures short-circuit: exactly one attempt.
+ expect(createMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("retries a transient error and then succeeds", async () => {
+ vi.useFakeTimers();
+ createMock
+ .mockRejectedValueOnce(Object.assign(new Error("Server Error"), { status: 500 }))
+ .mockResolvedValueOnce(textMessage("Recovered Title"));
+
+ const promise = generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx");
+ // Drain the backoff setTimeout so the retry fires without waiting real seconds.
+ await vi.runAllTimersAsync();
+ const result = await promise;
+
+ expect(result).toBe("Recovered Title");
+ expect(createMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/src/lib/openai.ts b/app/src/lib/anthropic.ts
similarity index 70%
rename from app/src/lib/openai.ts
rename to app/src/lib/anthropic.ts
index a9de48d..5e76dbd 100644
--- a/app/src/lib/openai.ts
+++ b/app/src/lib/anthropic.ts
@@ -1,18 +1,34 @@
-import OpenAI from "openai";
+import Anthropic from "@anthropic-ai/sdk";
import { getLanguageByCode } from "./languages";
+// Direct browser→Anthropic path for Pro users who bring their own key. The key
+// never transits Optia's backend (that path is the hosted proxy in ai-proxy.ts).
+// Free/entitlement-metered generation goes through the proxy instead.
+
+// SEO snippet generation is short and latency-sensitive; Opus 4.8 is the default
+// per Anthropic guidance. Change AI_MODEL if a cheaper tier is preferred.
+export const AI_MODEL = "claude-opus-4-8";
+const MAX_TOKENS = 1024;
+
const isDevMode =
typeof window !== "undefined" && window.location?.hostname === "localhost";
-function createClient(apiKey: string): OpenAI {
- return new OpenAI({
+function createClient(apiKey: string): Anthropic {
+ return new Anthropic({
apiKey,
dangerouslyAllowBrowser: true,
- ...(isDevMode ? { baseURL: `${window.location.origin}/api/openai` } : {}),
+ ...(isDevMode ? { baseURL: `${window.location.origin}/api/anthropic` } : {}),
});
}
-async function chatWithRetry(
+function extractText(message: Anthropic.Message): string {
+ const block = message.content.find((b) => b.type === "text");
+ const text = block && block.type === "text" ? block.text.trim() : "";
+ // Strip wrapping quotes the model sometimes adds
+ return text.replace(/^["']|["']$/g, "");
+}
+
+async function completeWithRetry(
apiKey: string,
systemPrompt: string,
userPrompt: string,
@@ -23,20 +39,15 @@ async function chatWithRetry(
while (retries <= maxRetries) {
try {
- const response = await client.chat.completions.create({
- model: "gpt-3.5-turbo",
- messages: [
- { role: "system", content: systemPrompt },
- { role: "user", content: userPrompt },
- ],
- max_tokens: 500,
- temperature: 0.7,
+ const message = await client.messages.create({
+ model: AI_MODEL,
+ max_tokens: MAX_TOKENS,
+ system: systemPrompt,
+ messages: [{ role: "user", content: userPrompt }],
});
- const text = response.choices[0]?.message?.content?.trim() ?? "";
- // Strip wrapping quotes if present
- return text.replace(/^["']|["']$/g, "");
+ return extractText(message);
} catch (error: unknown) {
- // Don't retry auth or billing errors — they won't resolve
+ // Auth/permission errors won't resolve on retry
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) throw error;
if (retries === maxRetries) throw error;
@@ -47,7 +58,7 @@ async function chatWithRetry(
throw new Error("Max retries exceeded");
}
-interface AdvancedOptions {
+export interface AdvancedOptions {
pageType?: string;
secondaryKeywords?: string;
languageCode?: string;
@@ -71,7 +82,8 @@ function buildLanguageInstruction(langCode?: string): string {
return `\n\nIMPORTANT: Generate all content in ${lang.name} (${lang.nativeName}). Provide recommendations entirely in this language.`;
}
-export async function generateRecommendation(
+/** Direct Anthropic generation for a single SEO check (BYO-key path). */
+export async function generateRecommendationDirect(
apiKey: string,
checkId: string,
keyword: string,
@@ -94,7 +106,7 @@ Provide a concise recommendation for improving this SEO issue.${advCtx ? " Consi
switch (checkId) {
case "title-keyword":
- return chatWithRetry(apiKey, copyableSystem,
+ return completeWithRetry(apiKey, copyableSystem,
`Create a perfect SEO title for the keyphrase "${keyword}".
Current title: ${context}${advCtx}
Requirements:
@@ -104,7 +116,7 @@ Requirements:
Return ONLY the title text.`);
case "meta-description-keyword":
- return chatWithRetry(apiKey, copyableSystem,
+ return completeWithRetry(apiKey, copyableSystem,
`Create a perfect meta description for the keyphrase "${keyword}".
Current description: ${context}${advCtx}
Requirements:
@@ -114,7 +126,7 @@ Requirements:
Return ONLY the description text.`);
case "keyword-url":
- return chatWithRetry(apiKey, copyableSystem,
+ return completeWithRetry(apiKey, copyableSystem,
`Create an SEO-friendly URL slug for the keyphrase "${keyword}".
Current URL: ${context}${advCtx}
Requirements:
@@ -127,7 +139,7 @@ Requirements:
Return ONLY the page slug with no slashes, protocol, or domain.`);
case "h1-keyword":
- return chatWithRetry(apiKey, copyableSystem,
+ return completeWithRetry(apiKey, copyableSystem,
`Create a perfect H1 heading for the keyphrase "${keyword}".
Current H1: ${context}${advCtx}
Requirements:
@@ -137,7 +149,7 @@ Requirements:
Return ONLY the H1 heading text.`);
case "keyword-intro":
- return chatWithRetry(apiKey, copyableSystem,
+ return completeWithRetry(apiKey, copyableSystem,
`Rewrite this introduction to naturally include the keyphrase "${keyword}".
Current introduction: ${context}${advCtx}
Requirements:
@@ -147,32 +159,16 @@ Requirements:
- Make it engaging${pageTypeStr}
Return ONLY the rewritten introduction.`);
- case "keyword-density":
- case "word-count":
- case "heading-hierarchy":
- case "internal-links":
- case "outbound-links":
- case "next-gen-images":
- case "code-minification":
- case "schema-markup":
- case "image-file-size":
- case "og-image":
- case "og-tags":
- case "canonical":
- case "lang":
- return chatWithRetry(apiKey, advisorySystem,
+ default:
+ return completeWithRetry(apiKey, advisorySystem,
`Fix this SEO issue: "${checkId}" for keyphrase "${keyword}" if applicable.
Current status: ${context}${advCtx}
Provide concise but actionable advice in 2-3 sentences${pageTypeStr}.`);
-
- default:
- return chatWithRetry(apiKey, advisorySystem,
- `Provide SEO advice for: "${checkId}" regarding keyphrase "${keyword}".
-Context: ${context}${advCtx}`);
}
}
-export async function generateH2Suggestion(
+/** Direct Anthropic H2 heading generation (BYO-key path). */
+export async function generateH2SuggestionDirect(
apiKey: string,
h2Text: string,
keyword: string,
@@ -184,7 +180,7 @@ export async function generateH2Suggestion(
? ` for a ${advancedOptions.pageType.replace("-", " ")}`
: "";
- return chatWithRetry(
+ return completeWithRetry(
apiKey,
`You are an SEO expert providing ready-to-use content.
Return ONLY the final H2 heading text with no explanation, quotes, or formatting.${langInst}`,
@@ -200,19 +196,8 @@ Return ONLY the H2 heading text.`,
);
}
-export async function generateAllH2Suggestions(
- apiKey: string,
- h2Texts: string[],
- keyword: string,
- advancedOptions?: AdvancedOptions,
-): Promise {
- const results = await Promise.all(
- h2Texts.map((text) => generateH2Suggestion(apiKey, text, keyword, advancedOptions)),
- );
- return results;
-}
-
-export async function generateAltText(
+/** Direct Anthropic alt-text generation (BYO-key path). */
+export async function generateAltTextDirect(
apiKey: string,
imageSrc: string,
keyword: string,
@@ -222,7 +207,7 @@ export async function generateAltText(
const langInst = buildLanguageInstruction(advancedOptions?.languageCode);
const filename = imageSrc.split("/").pop()?.split("?")[0] ?? "unknown";
- return chatWithRetry(
+ return completeWithRetry(
apiKey,
`You are an SEO and accessibility expert. Return ONLY the alt text string with no explanations, quotes, or formatting.${langInst}`,
`Create a concise, descriptive alt tag for this image that naturally incorporates the keyphrase "${keyword}".
@@ -236,22 +221,3 @@ Requirements:
Return ONLY the alt text.`,
);
}
-
-// Legacy exports for backward compatibility in SubscoresPage
-export async function generateTitle(
- apiKey: string,
- keyword: string,
- context: string,
- advancedOptions?: AdvancedOptions,
-): Promise {
- return generateRecommendation(apiKey, "title-keyword", keyword, context, advancedOptions);
-}
-
-export async function generateMetaDescription(
- apiKey: string,
- keyword: string,
- context: string,
- advancedOptions?: AdvancedOptions,
-): Promise {
- return generateRecommendation(apiKey, "meta-description-keyword", keyword, context, advancedOptions);
-}
diff --git a/app/src/lib/entitlement-store.test.ts b/app/src/lib/entitlement-store.test.ts
index f12a8ef..7f49c1f 100644
--- a/app/src/lib/entitlement-store.test.ts
+++ b/app/src/lib/entitlement-store.test.ts
@@ -1,42 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook } from "@testing-library/react";
import {
useEntitlementStore,
- canUseAINow,
- isMeteredAiCall,
+ useAiStatus,
+ useCanUseAI,
+ aiStatusNow,
initEntitlementSync,
} from "@/lib/entitlement-store";
import { useStore } from "@/lib/store";
import {
- activate,
- consumeAiQuota,
- deactivate,
- getAiQuotaRemaining,
getValidEntitlement,
+ getFreeAiQuota,
+ getProAiRemaining,
hasStoredLicenseKey,
+ activate,
+ deactivate,
+ recordProAiQuota,
+ recordFreeAiQuota,
ENTITLEMENT_TOKEN_KEY,
+ LICENSE_KEY_KEY,
+ PRO_AI_QUOTA_KEY,
+ FREE_AI_QUOTA_KEY,
type EntitlementClaims,
+ type QuotaSnapshot,
} from "@/lib/entitlement";
import { LicenseError } from "@/lib/backend";
+// The store owns the feature flags; the entitlement layer underneath is mocked
+// so these tests drive the flag/quota logic without jose, storage, or network.
+// Constants (the watched storage keys) stay real via importOriginal.
vi.mock("@/lib/entitlement", async (importOriginal) => {
const original = await importOriginal();
return {
...original,
getValidEntitlement: vi.fn(),
- getAiQuotaRemaining: vi.fn(),
+ getFreeAiQuota: vi.fn(),
+ getProAiRemaining: vi.fn(),
hasStoredLicenseKey: vi.fn(),
activate: vi.fn(),
deactivate: vi.fn(),
- consumeAiQuota: vi.fn(),
+ recordProAiQuota: vi.fn(),
+ recordFreeAiQuota: vi.fn(),
};
});
const getValidEntitlementMock = vi.mocked(getValidEntitlement);
-const getAiQuotaRemainingMock = vi.mocked(getAiQuotaRemaining);
+const getFreeAiQuotaMock = vi.mocked(getFreeAiQuota);
+const getProAiRemainingMock = vi.mocked(getProAiRemaining);
const hasStoredLicenseKeyMock = vi.mocked(hasStoredLicenseKey);
const activateMock = vi.mocked(activate);
const deactivateMock = vi.mocked(deactivate);
-const consumeAiQuotaMock = vi.mocked(consumeAiQuota);
+const recordProAiQuotaMock = vi.mocked(recordProAiQuota);
+const recordFreeAiQuotaMock = vi.mocked(recordFreeAiQuota);
const proClaims: EntitlementClaims = {
sub: "lic_1",
@@ -48,6 +63,9 @@ const proClaims: EntitlementClaims = {
iat: Math.floor(Date.now() / 1000),
};
+const freeSnapshot: QuotaSnapshot = { period: "2026-07", remaining: 4, limit: 10 };
+
+// Full free/default shape — resets every store field so no test leaks state.
function resetEntitlementState() {
useEntitlementStore.setState({
entitlementLoaded: false,
@@ -56,7 +74,12 @@ function resetEntitlementState() {
expiresAt: null,
quotaLimit: 0,
aiQuotaRemaining: 0,
+ freeAiRemaining: null,
+ freeAiLimit: null,
canUseAdvancedOptions: false,
+ canUseMultiLanguage: false,
+ canUseSchema: false,
+ canBringOwnKey: false,
hasLicenseKey: false,
activating: false,
activationError: null,
@@ -68,14 +91,16 @@ beforeEach(() => {
useStore.setState({ apiKey: "" });
hasStoredLicenseKeyMock.mockResolvedValue(false);
getValidEntitlementMock.mockResolvedValue(null);
- getAiQuotaRemainingMock.mockResolvedValue(0);
+ getFreeAiQuotaMock.mockResolvedValue(null);
+ getProAiRemainingMock.mockResolvedValue(0);
});
describe("hydrateEntitlement", () => {
- it("maps a valid Pro entitlement to flags", async () => {
+ it("maps a valid Pro entitlement to all Pro flags", async () => {
getValidEntitlementMock.mockResolvedValue(proClaims);
- getAiQuotaRemainingMock.mockResolvedValue(42);
+ getProAiRemainingMock.mockResolvedValue(42);
hasStoredLicenseKeyMock.mockResolvedValue(true);
+ getFreeAiQuotaMock.mockResolvedValue(freeSnapshot);
await useEntitlementStore.getState().hydrateEntitlement();
@@ -86,56 +111,179 @@ describe("hydrateEntitlement", () => {
expect(state.expiresAt).toBe(proClaims.exp * 1000);
expect(state.quotaLimit).toBe(100);
expect(state.aiQuotaRemaining).toBe(42);
- expect(state.canUseAdvancedOptions).toBe(true);
expect(state.hasLicenseKey).toBe(true);
+ expect(state.canUseAdvancedOptions).toBe(true);
+ expect(state.canUseMultiLanguage).toBe(true);
+ expect(state.canUseSchema).toBe(true);
+ expect(state.canBringOwnKey).toBe(true);
+ // Free-quota snapshot is populated even on the Pro branch.
+ expect(state.freeAiRemaining).toBe(4);
+ expect(state.freeAiLimit).toBe(10);
+ });
+
+ it("reads the Pro remaining quota from getProAiRemaining(claims)", async () => {
+ getValidEntitlementMock.mockResolvedValue(proClaims);
+ getProAiRemainingMock.mockResolvedValue(7);
+
+ await useEntitlementStore.getState().hydrateEntitlement();
+
+ expect(getProAiRemainingMock).toHaveBeenCalledWith(proClaims);
+ expect(useEntitlementStore.getState().aiQuotaRemaining).toBe(7);
});
- it("resolves missing or expired entitlements to the free tier", async () => {
+ it("resolves a missing or expired entitlement to free defaults", async () => {
getValidEntitlementMock.mockResolvedValue(null);
+ hasStoredLicenseKeyMock.mockResolvedValue(true);
await useEntitlementStore.getState().hydrateEntitlement();
const state = useEntitlementStore.getState();
expect(state.entitlementLoaded).toBe(true);
expect(state.isPro).toBe(false);
+ expect(state.tier).toBe("free");
+ expect(state.expiresAt).toBeNull();
+ expect(state.quotaLimit).toBe(0);
expect(state.aiQuotaRemaining).toBe(0);
expect(state.canUseAdvancedOptions).toBe(false);
+ expect(state.canUseMultiLanguage).toBe(false);
+ expect(state.canUseSchema).toBe(false);
+ expect(state.canBringOwnKey).toBe(false);
+ // hasLicenseKey is independent of tier (a stored key with no valid token).
+ expect(state.hasLicenseKey).toBe(true);
});
- it("never grants Pro for a non-pro tier claim", async () => {
+ it("never grants Pro for a non-'pro' tier claim", async () => {
getValidEntitlementMock.mockResolvedValue({ ...proClaims, tier: "trial" });
await useEntitlementStore.getState().hydrateEntitlement();
- expect(useEntitlementStore.getState().isPro).toBe(false);
+ const state = useEntitlementStore.getState();
+ expect(state.isPro).toBe(false);
+ expect(state.canUseAdvancedOptions).toBe(false);
+ expect(state.canBringOwnKey).toBe(false);
});
-});
-describe("canUseAINow / isMeteredAiCall", () => {
- it("follows the apiKey × isPro × quota truth table", () => {
- // free, no key
- expect(canUseAINow()).toBe(false);
- expect(isMeteredAiCall()).toBe(false);
+ it("populates free quota from a snapshot on the free tier", async () => {
+ getValidEntitlementMock.mockResolvedValue(null);
+ getFreeAiQuotaMock.mockResolvedValue({ period: "2026-07", remaining: 3, limit: 5 });
+
+ await useEntitlementStore.getState().hydrateEntitlement();
+
+ const state = useEntitlementStore.getState();
+ expect(state.freeAiRemaining).toBe(3);
+ expect(state.freeAiLimit).toBe(5);
+ });
+
+ it("leaves free quota null when no snapshot exists this period", async () => {
+ getValidEntitlementMock.mockResolvedValue(null);
+ getFreeAiQuotaMock.mockResolvedValue(null);
+
+ await useEntitlementStore.getState().hydrateEntitlement();
+
+ const state = useEntitlementStore.getState();
+ expect(state.freeAiRemaining).toBeNull();
+ expect(state.freeAiLimit).toBeNull();
+ });
+});
- // own key always wins, never metered
+describe("aiStatusNow (computeAiStatus truth table)", () => {
+ it("Pro + own key → byok, unlimited", () => {
useStore.setState({ apiKey: "sk-own" });
- expect(canUseAINow()).toBe(true);
- expect(isMeteredAiCall()).toBe(false);
+ useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 0, quotaLimit: 100 });
+
+ expect(aiStatusNow()).toEqual({ mode: "byok", remaining: null, limit: null });
+ });
+
+ it("Pro + no key + quota remaining → pro (metered)", () => {
+ useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5, quotaLimit: 100 });
+
+ expect(aiStatusNow()).toEqual({ mode: "pro", remaining: 5, limit: 100 });
+ });
+
+ it("Pro + no key + quota exhausted → locked", () => {
+ useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 0, quotaLimit: 100 });
+
+ expect(aiStatusNow()).toEqual({ mode: "locked", remaining: 0, limit: 100 });
+ });
+
+ it("free + own key → free (a stray key never grants byok)", () => {
+ useStore.setState({ apiKey: "sk-stray" });
+ useEntitlementStore.setState({ isPro: false, freeAiRemaining: 3, freeAiLimit: 10 });
+
+ expect(aiStatusNow()).toEqual({ mode: "free", remaining: 3, limit: 10 });
+ });
+
+ it("free + unknown remaining (null) → free (unknown is available)", () => {
+ useEntitlementStore.setState({ isPro: false, freeAiRemaining: null, freeAiLimit: null });
+
+ expect(aiStatusNow()).toEqual({ mode: "free", remaining: null, limit: null });
+ });
+
+ it("free + remaining > 0 → free", () => {
+ useEntitlementStore.setState({ isPro: false, freeAiRemaining: 2, freeAiLimit: 5 });
- // pro with quota, no key: allowed and metered
- useStore.setState({ apiKey: "" });
- useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5 });
- expect(canUseAINow()).toBe(true);
- expect(isMeteredAiCall()).toBe(true);
+ expect(aiStatusNow()).toEqual({ mode: "free", remaining: 2, limit: 5 });
+ });
+
+ it("free + remaining 0 → locked", () => {
+ useEntitlementStore.setState({ isPro: false, freeAiRemaining: 0, freeAiLimit: 5 });
- // pro with exhausted quota, no key: blocked
- useEntitlementStore.setState({ aiQuotaRemaining: 0 });
- expect(canUseAINow()).toBe(false);
+ expect(aiStatusNow()).toEqual({ mode: "locked", remaining: 0, limit: 5 });
+ });
+});
- // pro with exhausted quota but own key: allowed, not metered
+describe("useAiStatus / useCanUseAI (reactive hooks)", () => {
+ it("useAiStatus mirrors the computed status", () => {
useStore.setState({ apiKey: "sk-own" });
- expect(canUseAINow()).toBe(true);
- expect(isMeteredAiCall()).toBe(false);
+ useEntitlementStore.setState({ isPro: true });
+
+ const { result } = renderHook(() => useAiStatus());
+ expect(result.current).toEqual({ mode: "byok", remaining: null, limit: null });
+ });
+
+ it("useCanUseAI is true while AI is available", () => {
+ useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5, quotaLimit: 100 });
+
+ const { result } = renderHook(() => useCanUseAI());
+ expect(result.current).toBe(true);
+ });
+
+ it("useCanUseAI is false when the mode is locked", () => {
+ useEntitlementStore.setState({ isPro: false, freeAiRemaining: 0, freeAiLimit: 5 });
+
+ const { result } = renderHook(() => useCanUseAI());
+ expect(result.current).toBe(false);
+ });
+});
+
+describe("applyProxyQuota", () => {
+ it("records and reflects a Pro quota snapshot", async () => {
+ const quota: QuotaSnapshot = { period: "2026-07", remaining: 7, limit: 100 };
+
+ await useEntitlementStore.getState().applyProxyQuota(quota, "pro");
+
+ expect(recordProAiQuotaMock).toHaveBeenCalledWith(quota);
+ expect(recordFreeAiQuotaMock).not.toHaveBeenCalled();
+ const state = useEntitlementStore.getState();
+ expect(state.aiQuotaRemaining).toBe(7);
+ expect(state.quotaLimit).toBe(100);
+ // The free fields are untouched by a Pro-metered call.
+ expect(state.freeAiRemaining).toBeNull();
+ expect(state.freeAiLimit).toBeNull();
+ });
+
+ it("records and reflects a free quota snapshot", async () => {
+ const quota: QuotaSnapshot = { period: "2026-07", remaining: 2, limit: 5 };
+
+ await useEntitlementStore.getState().applyProxyQuota(quota, "free");
+
+ expect(recordFreeAiQuotaMock).toHaveBeenCalledWith(quota);
+ expect(recordProAiQuotaMock).not.toHaveBeenCalled();
+ const state = useEntitlementStore.getState();
+ expect(state.freeAiRemaining).toBe(2);
+ expect(state.freeAiLimit).toBe(5);
+ // The Pro remaining is untouched by a free-metered call.
+ expect(state.aiQuotaRemaining).toBe(0);
});
});
@@ -143,7 +291,7 @@ describe("activateLicense", () => {
it("activates, rehydrates, and reports success", async () => {
activateMock.mockResolvedValue(proClaims);
getValidEntitlementMock.mockResolvedValue(proClaims);
- getAiQuotaRemainingMock.mockResolvedValue(100);
+ getProAiRemainingMock.mockResolvedValue(100);
hasStoredLicenseKeyMock.mockResolvedValue(true);
const ok = await useEntitlementStore.getState().activateLicense(" optia_live_key ");
@@ -164,6 +312,7 @@ describe("activateLicense", () => {
expect(ok).toBe(false);
const state = useEntitlementStore.getState();
expect(state.isPro).toBe(false);
+ expect(state.activating).toBe(false);
expect(state.activationError).toBe("This license key is not valid.");
});
@@ -176,57 +325,85 @@ describe("activateLicense", () => {
"Too many attempts. Try again in 30s.",
);
});
+
+ it("omits the retry delay when the server gives no Retry-After", async () => {
+ activateMock.mockRejectedValue(new LicenseError("rate_limited", "slow down"));
+
+ await useEntitlementStore.getState().activateLicense("key");
+
+ expect(useEntitlementStore.getState().activationError).toBe("Too many attempts.");
+ });
+
+ it("maps an unexpected (non-LicenseError) failure to a generic message", async () => {
+ activateMock.mockRejectedValue(new Error("boom"));
+
+ const ok = await useEntitlementStore.getState().activateLicense("key");
+
+ expect(ok).toBe(false);
+ expect(useEntitlementStore.getState().activationError).toBe(
+ "Activation failed. Please try again.",
+ );
+ });
});
describe("deactivateLicense", () => {
- it("clears flags back to free", async () => {
+ it("clears all flags back to the free tier", async () => {
useEntitlementStore.setState({
isPro: true,
tier: "pro",
aiQuotaRemaining: 10,
+ quotaLimit: 100,
+ expiresAt: Date.now(),
canUseAdvancedOptions: true,
+ canUseMultiLanguage: true,
+ canUseSchema: true,
+ canBringOwnKey: true,
hasLicenseKey: true,
+ activationError: "prior error",
});
deactivateMock.mockResolvedValue();
+ getValidEntitlementMock.mockResolvedValue(null);
+ hasStoredLicenseKeyMock.mockResolvedValue(false);
await useEntitlementStore.getState().deactivateLicense();
- const state = useEntitlementStore.getState();
expect(deactivateMock).toHaveBeenCalled();
+ const state = useEntitlementStore.getState();
expect(state.isPro).toBe(false);
+ expect(state.tier).toBe("free");
expect(state.hasLicenseKey).toBe(false);
expect(state.aiQuotaRemaining).toBe(0);
+ expect(state.quotaLimit).toBe(0);
+ expect(state.expiresAt).toBeNull();
expect(state.canUseAdvancedOptions).toBe(false);
- });
-});
-
-describe("consumeAiQuota", () => {
- it("updates the remaining quota flag", async () => {
- useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5 });
- consumeAiQuotaMock.mockResolvedValue(4);
-
- await useEntitlementStore.getState().consumeAiQuota();
-
- expect(useEntitlementStore.getState().aiQuotaRemaining).toBe(4);
+ expect(state.canUseMultiLanguage).toBe(false);
+ expect(state.canUseSchema).toBe(false);
+ expect(state.canBringOwnKey).toBe(false);
+ expect(state.activationError).toBeNull();
});
});
describe("initEntitlementSync", () => {
- it("rehydrates when entitlement storage keys change in another context", () => {
+ it("re-hydrates on watched local key changes, and is idempotent", () => {
initEntitlementSync();
- initEntitlementSync(); // idempotent
+ initEntitlementSync(); // idempotent: only one listener is ever registered
const addListener = vi.mocked(chrome.storage.onChanged.addListener);
expect(addListener).toHaveBeenCalledTimes(1);
const listener = addListener.mock.calls[0][0];
- getValidEntitlementMock.mockResolvedValue(proClaims);
+ getValidEntitlementMock.mockResolvedValue(null);
- listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "t" } }, "local");
- expect(getValidEntitlementMock).toHaveBeenCalled();
+ // Each of the four watched keys re-triggers hydration.
+ for (const key of [ENTITLEMENT_TOKEN_KEY, LICENSE_KEY_KEY, PRO_AI_QUOTA_KEY, FREE_AI_QUOTA_KEY]) {
+ getValidEntitlementMock.mockClear();
+ listener({ [key]: { newValue: "x" } }, "local");
+ expect(getValidEntitlementMock, `${key} should re-hydrate`).toHaveBeenCalled();
+ }
+ // Unrelated keys and non-local areas are ignored.
getValidEntitlementMock.mockClear();
listener({ unrelated_key: { newValue: 1 } }, "local");
- listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "t" } }, "session");
+ listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "x" } }, "session");
expect(getValidEntitlementMock).not.toHaveBeenCalled();
});
});
diff --git a/app/src/lib/entitlement-store.ts b/app/src/lib/entitlement-store.ts
index acd4b4c..cc5fa62 100644
--- a/app/src/lib/entitlement-store.ts
+++ b/app/src/lib/entitlement-store.ts
@@ -1,38 +1,52 @@
import { create } from "zustand";
import {
activate,
- consumeAiQuota,
deactivate,
- getAiQuotaRemaining,
+ getFreeAiQuota,
+ getProAiRemaining,
getValidEntitlement,
hasStoredLicenseKey,
- AI_USAGE_KEY,
+ recordFreeAiQuota,
+ recordProAiQuota,
ENTITLEMENT_TOKEN_KEY,
+ FREE_AI_QUOTA_KEY,
LICENSE_KEY_KEY,
+ PRO_AI_QUOTA_KEY,
+ type QuotaSnapshot,
} from "@/lib/entitlement";
import { LicenseError } from "@/lib/backend";
import { useStore } from "@/lib/store";
// Feature-flag store over the entitlement layer. The rest of the app reads
-// these flags (and useCanUseAI) — never raw license/token data.
+// these flags (useCanUseAI / useAiStatus / the canUse* booleans) — never raw
+// license/token data.
interface EntitlementStore {
entitlementLoaded: boolean;
isPro: boolean;
tier: "free" | "pro";
expiresAt: number | null; // ms epoch, for status display
- quotaLimit: number;
- aiQuotaRemaining: number;
+ quotaLimit: number; // Pro monthly limit from the token (0 for free)
+ aiQuotaRemaining: number; // Pro remaining, server-authoritative (0 for free)
+ aiQuotaPeriod: string | null; // period the Pro remaining belongs to (YYYY-MM)
+ freeAiRemaining: number | null; // free monthly remaining; null = unknown (no call yet this period)
+ freeAiLimit: number | null;
+ freeAiPeriod: string | null; // period the free remaining belongs to (YYYY-MM)
canUseAdvancedOptions: boolean;
+ canUseMultiLanguage: boolean;
+ canUseSchema: boolean;
+ canBringOwnKey: boolean;
hasLicenseKey: boolean;
activating: boolean;
activationError: string | null;
hydrateEntitlement: () => Promise;
activateLicense: (licenseKey: string) => Promise;
deactivateLicense: () => Promise;
- consumeAiQuota: () => Promise;
+ applyProxyQuota: (quota: QuotaSnapshot, subject: "pro" | "free") => Promise;
}
+// Reset shape for the free/expired tier. Free-quota fields are NOT reset here —
+// they are install-scoped and hydrated separately.
const freeFlags = {
isPro: false,
tier: "free" as const,
@@ -40,6 +54,9 @@ const freeFlags = {
quotaLimit: 0,
aiQuotaRemaining: 0,
canUseAdvancedOptions: false,
+ canUseMultiLanguage: false,
+ canUseSchema: false,
+ canBringOwnKey: false,
};
function activationErrorMessage(error: unknown): string {
@@ -56,6 +73,10 @@ function activationErrorMessage(error: unknown): string {
export const useEntitlementStore = create((set) => ({
entitlementLoaded: false,
...freeFlags,
+ aiQuotaPeriod: null,
+ freeAiRemaining: null,
+ freeAiLimit: null,
+ freeAiPeriod: null,
hasLicenseKey: false,
activating: false,
activationError: null,
@@ -63,8 +84,21 @@ export const useEntitlementStore = create((set) => ({
hydrateEntitlement: async () => {
const claims = await getValidEntitlement();
const hasLicenseKey = await hasStoredLicenseKey();
+ const freeQuota = await getFreeAiQuota();
+ const freeAiRemaining = freeQuota ? freeQuota.remaining : null;
+ const freeAiLimit = freeQuota ? freeQuota.limit : null;
+ const freeAiPeriod = freeQuota ? freeQuota.period : null;
+
if (!claims || claims.tier !== "pro") {
- set({ entitlementLoaded: true, hasLicenseKey, ...freeFlags });
+ set({
+ entitlementLoaded: true,
+ hasLicenseKey,
+ freeAiRemaining,
+ freeAiLimit,
+ freeAiPeriod,
+ aiQuotaPeriod: null,
+ ...freeFlags,
+ });
return;
}
set({
@@ -74,8 +108,15 @@ export const useEntitlementStore = create((set) => ({
tier: "pro",
expiresAt: claims.exp * 1000,
quotaLimit: claims.quotaLimit,
- aiQuotaRemaining: await getAiQuotaRemaining(claims),
+ aiQuotaRemaining: await getProAiRemaining(claims),
+ aiQuotaPeriod: claims.period,
+ freeAiRemaining,
+ freeAiLimit,
+ freeAiPeriod,
canUseAdvancedOptions: true,
+ canUseMultiLanguage: true,
+ canUseSchema: true,
+ canBringOwnKey: true,
});
},
@@ -94,51 +135,116 @@ export const useEntitlementStore = create((set) => ({
deactivateLicense: async () => {
await deactivate();
- set({ hasLicenseKey: false, activationError: null, ...freeFlags });
+ await useEntitlementStore.getState().hydrateEntitlement();
+ set({ activationError: null });
},
- consumeAiQuota: async () => {
- const remaining = await consumeAiQuota();
- set({ aiQuotaRemaining: remaining });
+ applyProxyQuota: async (quota, subject) => {
+ // Concurrent generations (generate-all) resolve out of order; keep the most
+ // conservative (lowest) remaining for the SAME period so the cache never
+ // overstates the allowance. A different period is a rollover — take it as-is.
+ const state = useEntitlementStore.getState();
+ if (subject === "pro") {
+ const known = state.quotaLimit > 0 ? state.aiQuotaRemaining : null;
+ const sameMonth = quota.period === state.aiQuotaPeriod;
+ const remaining = sameMonth && known !== null ? Math.min(known, quota.remaining) : quota.remaining;
+ const reconciled = { ...quota, remaining };
+ await recordProAiQuota(reconciled);
+ set({ aiQuotaRemaining: remaining, quotaLimit: reconciled.limit, aiQuotaPeriod: quota.period });
+ } else {
+ const sameMonth = quota.period === state.freeAiPeriod;
+ const remaining =
+ sameMonth && state.freeAiRemaining !== null
+ ? Math.min(state.freeAiRemaining, quota.remaining)
+ : quota.remaining;
+ const reconciled = { ...quota, remaining };
+ await recordFreeAiQuota(reconciled);
+ set({ freeAiRemaining: remaining, freeAiLimit: reconciled.limit, freeAiPeriod: quota.period });
+ }
},
}));
-/**
- * The single place AI availability is decided: users with their own OpenAI key
- * are never gated (and never metered); otherwise Pro with quota remaining.
- */
-export function useCanUseAI(): boolean {
- const apiKey = useStore((state) => state.apiKey);
- const isPro = useEntitlementStore((state) => state.isPro);
- const aiQuotaRemaining = useEntitlementStore((state) => state.aiQuotaRemaining);
- return Boolean(apiKey) || (isPro && aiQuotaRemaining > 0);
+// ── AI availability: the single place the three access paths are decided ──
+//
+// BYO key (Pro + own Anthropic key) → unlimited direct calls (never metered).
+// Pro without key → hosted proxy, higher monthly cap (server-metered).
+// Free → hosted proxy, capped monthly allowance (server-metered).
+// A stored key only unlocks the direct path when the user is Pro — BYO key is a
+// Pro feature, so free users can never self-serve AI with a key.
+
+export type AiMode = "byok" | "pro" | "free" | "locked";
+
+export interface AiStatus {
+ mode: AiMode;
+ remaining: number | null; // null = unlimited (byok) or unknown (free, pre-call)
+ limit: number | null;
+}
+
+interface AiInputs {
+ apiKey: string;
+ isPro: boolean;
+ aiQuotaRemaining: number;
+ quotaLimit: number;
+ freeAiRemaining: number | null;
+ freeAiLimit: number | null;
}
-/** Non-hook variant for imperative call sites (e.g. inside async handlers). */
-export function canUseAINow(): boolean {
- const { isPro, aiQuotaRemaining } = useEntitlementStore.getState();
- return Boolean(useStore.getState().apiKey) || (isPro && aiQuotaRemaining > 0);
+function computeAiStatus(i: AiInputs): AiStatus {
+ if (i.isPro && i.apiKey) return { mode: "byok", remaining: null, limit: null };
+ if (i.isPro) {
+ return i.aiQuotaRemaining > 0
+ ? { mode: "pro", remaining: i.aiQuotaRemaining, limit: i.quotaLimit }
+ : { mode: "locked", remaining: 0, limit: i.quotaLimit };
+ }
+ // Free tier: unknown remaining (null) is treated as available.
+ if (i.freeAiRemaining === null || i.freeAiRemaining > 0) {
+ return { mode: "free", remaining: i.freeAiRemaining, limit: i.freeAiLimit };
+ }
+ return { mode: "locked", remaining: 0, limit: i.freeAiLimit };
}
-/** True when the current AI call runs on Pro quota (no personal key) and must be metered. */
-export function isMeteredAiCall(): boolean {
- return !useStore.getState().apiKey && useEntitlementStore.getState().isPro;
+export function useAiStatus(): AiStatus {
+ const apiKey = useStore((s) => s.apiKey);
+ const isPro = useEntitlementStore((s) => s.isPro);
+ const aiQuotaRemaining = useEntitlementStore((s) => s.aiQuotaRemaining);
+ const quotaLimit = useEntitlementStore((s) => s.quotaLimit);
+ const freeAiRemaining = useEntitlementStore((s) => s.freeAiRemaining);
+ const freeAiLimit = useEntitlementStore((s) => s.freeAiLimit);
+ return computeAiStatus({ apiKey, isPro, aiQuotaRemaining, quotaLimit, freeAiRemaining, freeAiLimit });
+}
+
+export function useCanUseAI(): boolean {
+ return useAiStatus().mode !== "locked";
+}
+
+/** Non-reactive Ai status for imperative call sites (async handlers). */
+export function aiStatusNow(): AiStatus {
+ const e = useEntitlementStore.getState();
+ return computeAiStatus({
+ apiKey: useStore.getState().apiKey,
+ isPro: e.isPro,
+ aiQuotaRemaining: e.aiQuotaRemaining,
+ quotaLimit: e.quotaLimit,
+ freeAiRemaining: e.freeAiRemaining,
+ freeAiLimit: e.freeAiLimit,
+ });
}
let syncInitialized = false;
/**
- * Keeps the store in sync with entitlement changes made in other extension
- * contexts (options page, background refresh) via chrome.storage.onChanged.
- * Idempotent; a no-op outside a chrome-extension context (browser dev preview).
+ * Keeps the store in sync with entitlement/quota changes made in other
+ * extension contexts (options page, background refresh, a sidepanel AI call)
+ * via chrome.storage.onChanged. Idempotent; a no-op in the browser dev preview.
*/
export function initEntitlementSync(): void {
if (syncInitialized) return;
if (typeof chrome === "undefined" || !chrome.storage?.onChanged) return;
syncInitialized = true;
+ const watched = [ENTITLEMENT_TOKEN_KEY, LICENSE_KEY_KEY, PRO_AI_QUOTA_KEY, FREE_AI_QUOTA_KEY];
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
- if (changes[ENTITLEMENT_TOKEN_KEY] || changes[LICENSE_KEY_KEY] || changes[AI_USAGE_KEY]) {
+ if (watched.some((key) => changes[key])) {
void useEntitlementStore.getState().hydrateEntitlement();
}
});
diff --git a/app/src/lib/entitlement.test.ts b/app/src/lib/entitlement.test.ts
index 4539cb1..e883152 100644
--- a/app/src/lib/entitlement.test.ts
+++ b/app/src/lib/entitlement.test.ts
@@ -4,19 +4,26 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import {
activate,
- consumeAiQuota,
+ currentAiPeriod,
deactivate,
- getAiQuotaRemaining,
+ getFreeAiQuota,
getInstallId,
+ getProAiRemaining,
+ getRawEntitlementToken,
getRefreshFailureCount,
getValidEntitlement,
+ recordFreeAiQuota,
+ recordProAiQuota,
refreshNow,
verifyEntitlementToken,
- AI_USAGE_KEY,
ENTITLEMENT_TOKEN_KEY,
+ FREE_AI_QUOTA_KEY,
INSTALL_ID_KEY,
LICENSE_KEY_KEY,
+ PRO_AI_QUOTA_KEY,
REFRESH_FAILURES_KEY,
+ type EntitlementClaims,
+ type QuotaSnapshot,
} from "@/lib/entitlement";
import { getStorageItem, setStorageItem } from "@/lib/storage";
import {
@@ -137,6 +144,18 @@ describe("getValidEntitlement", () => {
});
});
+describe("getRawEntitlementToken", () => {
+ it("returns null when no token is cached", async () => {
+ expect(await getRawEntitlementToken()).toBeNull();
+ });
+
+ it("returns the raw stored token verbatim", async () => {
+ const token = await signTestToken(keys);
+ await setStorageItem(ENTITLEMENT_TOKEN_KEY, token);
+ expect(await getRawEntitlementToken()).toBe(token);
+ });
+});
+
describe("getInstallId", () => {
it("generates once and then returns the same id", async () => {
const first = await getInstallId();
@@ -210,13 +229,25 @@ describe("refreshNow", () => {
expect(await refreshNow(verifyOpts)).toBeNull();
});
- it("clears all license state when the license is revoked or unknown", async () => {
+ it("clears license state on a revoked/unknown license but keeps the free allowance", async () => {
await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys));
+ await setStorageItem(PRO_AI_QUOTA_KEY, {
+ period: TEST_PERIOD,
+ remaining: 5,
+ limit: 100,
+ } satisfies QuotaSnapshot);
+ await setStorageItem(FREE_AI_QUOTA_KEY, {
+ period: TEST_PERIOD,
+ remaining: 2,
+ limit: 5,
+ } satisfies QuotaSnapshot);
refreshTokenMock.mockRejectedValue(new LicenseError("invalid", "revoked"));
expect(await refreshNow(verifyOpts)).toBeNull();
expect(await getStorageItem(ENTITLEMENT_TOKEN_KEY)).toBeNull();
expect(await getStorageItem(LICENSE_KEY_KEY)).toBeNull();
+ expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toBeNull();
+ expect(await getStorageItem(FREE_AI_QUOTA_KEY)).not.toBeNull();
});
it("does not persist a tampered refresh response", async () => {
@@ -237,7 +268,16 @@ describe("deactivate", () => {
await setStorageItem(LICENSE_KEY_KEY, "optia_live_abc");
await setStorageItem(INSTALL_ID_KEY, "install-1");
await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys));
- await setStorageItem(AI_USAGE_KEY, { period: TEST_PERIOD, used: 5 });
+ await setStorageItem(PRO_AI_QUOTA_KEY, {
+ period: TEST_PERIOD,
+ remaining: 5,
+ limit: 100,
+ } satisfies QuotaSnapshot);
+ await setStorageItem(FREE_AI_QUOTA_KEY, {
+ period: TEST_PERIOD,
+ remaining: 2,
+ limit: 5,
+ } satisfies QuotaSnapshot);
deactivateLicenseMock.mockRejectedValue(new LicenseError("network", "offline"));
await deactivate();
@@ -245,39 +285,78 @@ describe("deactivate", () => {
expect(deactivateLicenseMock).toHaveBeenCalledWith("optia_live_abc", "install-1");
expect(await getStorageItem(ENTITLEMENT_TOKEN_KEY)).toBeNull();
expect(await getStorageItem(LICENSE_KEY_KEY)).toBeNull();
- expect(await getStorageItem(AI_USAGE_KEY)).toBeNull();
+ // Pro quota is license-scoped and cleared; the free allowance is
+ // install-scoped and survives deactivation.
+ expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toBeNull();
+ expect(await getStorageItem(FREE_AI_QUOTA_KEY)).not.toBeNull();
});
});
-describe("AI quota", () => {
- beforeEach(async () => {
- await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys, { quotaLimit: 3 }));
+describe("currentAiPeriod", () => {
+ it("formats a date as YYYY-MM", () => {
+ expect(currentAiPeriod(new Date("2026-07-15T12:00:00Z"))).toBe("2026-07");
+ expect(currentAiPeriod(new Date("2026-01-01T00:00:00Z"))).toBe("2026-01");
});
- it("computes remaining from the stored usage for the same period", async () => {
- const claims = await getValidEntitlement(verifyOpts);
- await setStorageItem(AI_USAGE_KEY, { period: TEST_PERIOD, used: 2 });
- expect(await getAiQuotaRemaining(claims!)).toBe(1);
+ it("defaults to the current month in YYYY-MM form", () => {
+ expect(currentAiPeriod()).toMatch(/^\d{4}-\d{2}$/);
});
+});
- it("consumes down to zero and never goes negative", async () => {
- expect(await consumeAiQuota(verifyOpts)).toBe(2);
- expect(await consumeAiQuota(verifyOpts)).toBe(1);
- expect(await consumeAiQuota(verifyOpts)).toBe(0);
- expect(await consumeAiQuota(verifyOpts)).toBe(0);
+describe("recordProAiQuota / getProAiRemaining", () => {
+ const proClaims: EntitlementClaims = {
+ sub: "lic_test_123",
+ subjectType: "license",
+ tier: "pro",
+ quotaLimit: 100,
+ period: TEST_PERIOD,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ };
+
+ it("falls back to the token's quotaLimit when nothing has been recorded", async () => {
+ expect(await getProAiRemaining(proClaims)).toBe(100);
});
- it("resets the counter when the token period rolls over", async () => {
- await setStorageItem(AI_USAGE_KEY, { period: "2026-06", used: 3 });
- const claims = await getValidEntitlement(verifyOpts);
- expect(await getAiQuotaRemaining(claims!)).toBe(3);
- expect(await consumeAiQuota(verifyOpts)).toBe(2);
- expect(await getStorageItem(AI_USAGE_KEY)).toEqual({ period: TEST_PERIOD, used: 1 });
+ it("returns the server-recorded remaining for a matching period", async () => {
+ await recordProAiQuota({ period: TEST_PERIOD, remaining: 7, limit: 100 });
+ expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toEqual({
+ period: TEST_PERIOD,
+ remaining: 7,
+ limit: 100,
+ });
+ expect(await getProAiRemaining(proClaims)).toBe(7);
+ });
+
+ it("ignores a stale-period snapshot and falls back to the token quotaLimit", async () => {
+ await recordProAiQuota({ period: "2026-06", remaining: 7, limit: 100 });
+ expect(await getProAiRemaining(proClaims)).toBe(100);
+ });
+
+ it("never reports a negative remaining", async () => {
+ await recordProAiQuota({ period: TEST_PERIOD, remaining: -5, limit: 100 });
+ expect(await getProAiRemaining(proClaims)).toBe(0);
+ });
+});
+
+describe("recordFreeAiQuota / getFreeAiQuota", () => {
+ it("returns null when nothing has been recorded", async () => {
+ expect(await getFreeAiQuota()).toBeNull();
+ });
+
+ it("returns the snapshot when its period is the current month", async () => {
+ const now = new Date("2026-07-15T00:00:00Z");
+ const snapshot: QuotaSnapshot = {
+ period: currentAiPeriod(now),
+ remaining: 3,
+ limit: 5,
+ };
+ await recordFreeAiQuota(snapshot);
+ expect(await getFreeAiQuota(now)).toEqual(snapshot);
});
- it("consumes nothing without a valid entitlement", async () => {
- await setStorageItem(ENTITLEMENT_TOKEN_KEY, "garbage");
- expect(await consumeAiQuota(verifyOpts)).toBe(0);
- expect(await getStorageItem(AI_USAGE_KEY)).toBeNull();
+ it("returns null for a stale-month snapshot", async () => {
+ await recordFreeAiQuota({ period: "2026-07", remaining: 3, limit: 5 });
+ expect(await getFreeAiQuota(new Date("2026-08-15T00:00:00Z"))).toBeNull();
});
});
diff --git a/app/src/lib/entitlement.ts b/app/src/lib/entitlement.ts
index ad886d3..5e23634 100644
--- a/app/src/lib/entitlement.ts
+++ b/app/src/lib/entitlement.ts
@@ -15,7 +15,11 @@ import { getStorageItem, removeStorageItem, setStorageItem } from "@/lib/storage
export const ENTITLEMENT_TOKEN_KEY = "entitlement_token";
export const LICENSE_KEY_KEY = "license_key";
export const INSTALL_ID_KEY = "install_id";
-export const AI_USAGE_KEY = "ai_usage";
+// Server-authoritative AI quota snapshots, refreshed from every /ai/generate
+// response. Pro quota is license-scoped; free quota is install-scoped and
+// survives license changes (it is the anonymous free allowance).
+export const PRO_AI_QUOTA_KEY = "pro_ai_quota";
+export const FREE_AI_QUOTA_KEY = "free_ai_quota";
export const REFRESH_FAILURES_KEY = "entitlement_refresh_failures";
const ISSUER = "optia-backend";
@@ -37,9 +41,16 @@ export interface VerifyOptions {
now?: Date;
}
-interface AiUsageRecord {
- period: string;
- used: number;
+/** A server-reported AI quota window (monthly). */
+export interface QuotaSnapshot {
+ period: string; // "YYYY-MM"
+ remaining: number;
+ limit: number;
+}
+
+/** Current monthly accounting period, matching the backend's `YYYY-MM`. */
+export function currentAiPeriod(now: Date = new Date()): string {
+ return now.toISOString().slice(0, 7);
}
/**
@@ -105,6 +116,15 @@ export async function hasStoredLicenseKey(): Promise {
return (await getStorageItem(LICENSE_KEY_KEY)) !== null;
}
+/**
+ * The raw stored entitlement JWS, for authenticating Pro-tier proxy calls
+ * (sent as X-Optia-Entitlement). Returns null when no token is cached. The
+ * backend re-verifies it; the client never trusts it beyond presenting it.
+ */
+export async function getRawEntitlementToken(): Promise {
+ return getStorageItem(ENTITLEMENT_TOKEN_KEY);
+}
+
/**
* The cached entitlement, if it still verifies and has not expired.
* Missing, expired, or tampered ⇒ null (free tier) — never Pro.
@@ -124,7 +144,9 @@ export async function getRefreshFailureCount(): Promise {
async function clearLocalLicenseState(): Promise {
await removeStorageItem(ENTITLEMENT_TOKEN_KEY);
await removeStorageItem(LICENSE_KEY_KEY);
- await removeStorageItem(AI_USAGE_KEY);
+ // Pro quota is license-scoped; the free allowance (FREE_AI_QUOTA_KEY) is
+ // install-scoped and intentionally survives deactivation.
+ await removeStorageItem(PRO_AI_QUOTA_KEY);
await removeStorageItem(REFRESH_FAILURES_KEY);
}
@@ -199,22 +221,33 @@ export async function refreshNow(
return claims;
}
-/** Remaining AI quota for the given claims' period (own-key calls are not metered). */
-export async function getAiQuotaRemaining(claims: EntitlementClaims): Promise {
- const usage = await getStorageItem(AI_USAGE_KEY);
- const used = usage && usage.period === claims.period ? usage.used : 0;
- return Math.max(0, claims.quotaLimit - used);
+/** Persist the server-reported Pro quota after an entitlement-metered proxy call. */
+export async function recordProAiQuota(quota: QuotaSnapshot): Promise {
+ await setStorageItem(PRO_AI_QUOTA_KEY, quota);
+}
+
+/** Persist the server-reported free quota after an install-metered proxy call. */
+export async function recordFreeAiQuota(quota: QuotaSnapshot): Promise {
+ await setStorageItem(FREE_AI_QUOTA_KEY, quota);
+}
+
+/**
+ * Remaining Pro quota for the entitlement's period. The server is authoritative
+ * (last /ai/generate response); before any call this period, the token's
+ * quotaLimit is the best estimate.
+ */
+export async function getProAiRemaining(claims: EntitlementClaims): Promise {
+ const quota = await getStorageItem(PRO_AI_QUOTA_KEY);
+ if (quota && quota.period === claims.period) return Math.max(0, quota.remaining);
+ return claims.quotaLimit;
}
/**
- * Records one metered AI call against the current period and returns the
- * remaining quota. No valid entitlement ⇒ 0, nothing recorded.
+ * The free-tier quota for the current month, or null when unknown (no proxy
+ * call yet this period). Unknown is treated as "available" by callers.
*/
-export async function consumeAiQuota(options: VerifyOptions = {}): Promise {
- const claims = await getValidEntitlement(options);
- if (!claims) return 0;
- const usage = await getStorageItem(AI_USAGE_KEY);
- const used = (usage && usage.period === claims.period ? usage.used : 0) + 1;
- await setStorageItem(AI_USAGE_KEY, { period: claims.period, used });
- return Math.max(0, claims.quotaLimit - used);
+export async function getFreeAiQuota(now: Date = new Date()): Promise {
+ const quota = await getStorageItem(FREE_AI_QUOTA_KEY);
+ if (quota && quota.period === currentAiPeriod(now)) return quota;
+ return null;
}
diff --git a/app/src/lib/openai.test.ts b/app/src/lib/openai.test.ts
deleted file mode 100644
index 04e7b0e..0000000
--- a/app/src/lib/openai.test.ts
+++ /dev/null
@@ -1,480 +0,0 @@
-import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
-
-// Create a mock for the chat.completions.create method
-const mockCreate = vi.fn();
-
-// Mock the OpenAI module before imports
-vi.mock("openai", () => {
- return {
- default: vi.fn().mockImplementation(() => ({
- chat: {
- completions: {
- create: mockCreate,
- },
- },
- })),
- };
-});
-
-// Import after mocking
-import {
- generateRecommendation,
- generateH2Suggestion,
- generateAllH2Suggestions,
- generateAltText,
- generateTitle,
- generateMetaDescription,
-} from "./openai";
-
-function mockSuccessResponse(content: string) {
- mockCreate.mockResolvedValueOnce({
- choices: [{ message: { content } }],
- });
-}
-
-function mockErrorResponse(status: number, message: string) {
- const error = new Error(message) as Error & { status: number };
- error.status = status;
- mockCreate.mockRejectedValueOnce(error);
-}
-
-describe("openai", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockCreate.mockReset();
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe("generateRecommendation", () => {
- it("generates a title recommendation", async () => {
- mockSuccessResponse("Best SEO Tips for 2024 | Complete Guide");
-
- const result = await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO tips",
- "My Blog About SEO",
- );
-
- expect(result).toBe("Best SEO Tips for 2024 | Complete Guide");
- expect(mockCreate).toHaveBeenCalledOnce();
- expect(mockCreate).toHaveBeenCalledWith(
- expect.objectContaining({
- model: "gpt-3.5-turbo",
- max_tokens: 500,
- temperature: 0.7,
- }),
- );
- });
-
- it("generates a meta description recommendation", async () => {
- mockSuccessResponse("Learn the best SEO tips to improve your rankings. Get actionable advice now!");
-
- const result = await generateRecommendation(
- "test-api-key",
- "meta-description-keyword",
- "SEO tips",
- "Current description here",
- );
-
- expect(result).toBe("Learn the best SEO tips to improve your rankings. Get actionable advice now!");
- });
-
- it("generates a URL slug recommendation", async () => {
- mockSuccessResponse("seo-tips-guide");
-
- const result = await generateRecommendation(
- "test-api-key",
- "keyword-url",
- "SEO tips",
- "https://example.com/old-url",
- );
-
- expect(result).toBe("seo-tips-guide");
- });
-
- it("generates an H1 recommendation", async () => {
- mockSuccessResponse("The Ultimate Guide to SEO Tips");
-
- const result = await generateRecommendation(
- "test-api-key",
- "h1-keyword",
- "SEO tips",
- "Welcome to my site",
- );
-
- expect(result).toBe("The Ultimate Guide to SEO Tips");
- });
-
- it("generates an intro paragraph recommendation", async () => {
- mockSuccessResponse("Discover essential SEO tips that will transform your website's visibility.");
-
- const result = await generateRecommendation(
- "test-api-key",
- "keyword-intro",
- "SEO tips",
- "This is my introduction paragraph.",
- );
-
- expect(result).toBe("Discover essential SEO tips that will transform your website's visibility.");
- });
-
- it("generates advisory content for non-copyable checks", async () => {
- mockSuccessResponse("Increase your internal linking to improve SEO crawlability.");
-
- const result = await generateRecommendation(
- "test-api-key",
- "internal-links",
- "SEO tips",
- "Currently 2 internal links",
- );
-
- expect(result).toBe("Increase your internal linking to improve SEO crawlability.");
- });
-
- it("handles keyword-density check", async () => {
- mockSuccessResponse("Optimize keyword density for better SEO.");
-
- const result = await generateRecommendation(
- "test-api-key",
- "keyword-density",
- "SEO",
- "Low density",
- );
-
- expect(result).toBe("Optimize keyword density for better SEO.");
- });
-
- it("handles word-count check", async () => {
- mockSuccessResponse("Add more content.");
-
- const result = await generateRecommendation(
- "test-api-key",
- "word-count",
- "SEO",
- "300 words",
- );
-
- expect(result).toBe("Add more content.");
- });
-
- it("handles unknown check IDs with generic advice", async () => {
- mockSuccessResponse("Generic SEO advice for this issue.");
-
- const result = await generateRecommendation(
- "test-api-key",
- "unknown-check-id",
- "SEO tips",
- "Some context",
- );
-
- expect(result).toBe("Generic SEO advice for this issue.");
- });
-
- it("strips wrapping double quotes from response", async () => {
- mockSuccessResponse('"Best SEO Tips for 2024"');
-
- const result = await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO tips",
- "Current title",
- );
-
- expect(result).toBe("Best SEO Tips for 2024");
- });
-
- it("strips wrapping single quotes from response", async () => {
- mockSuccessResponse("'Best SEO Tips for 2024'");
-
- const result = await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO tips",
- "Current title",
- );
-
- expect(result).toBe("Best SEO Tips for 2024");
- });
-
- it("includes advanced options in user prompt", async () => {
- mockSuccessResponse("SEO Tips for Your Landing Page");
-
- await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO tips",
- "Current title",
- {
- pageType: "landing-page",
- secondaryKeywords: "marketing, conversion",
- },
- );
-
- const callArgs = mockCreate.mock.calls[0][0];
- const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user");
- expect(userMessage.content).toContain("landing page");
- });
-
- it("throws immediately on 401 auth error without retry", async () => {
- mockErrorResponse(401, "Invalid API key");
-
- await expect(
- generateRecommendation("bad-key", "title-keyword", "SEO", "context"),
- ).rejects.toThrow("Invalid API key");
-
- // Should only be called once (no retries for auth errors)
- expect(mockCreate).toHaveBeenCalledOnce();
- });
-
- it("throws immediately on 403 forbidden error without retry", async () => {
- mockErrorResponse(403, "Forbidden");
-
- await expect(
- generateRecommendation("bad-key", "title-keyword", "SEO", "context"),
- ).rejects.toThrow("Forbidden");
-
- expect(mockCreate).toHaveBeenCalledOnce();
- });
-
- it("retries on transient errors then succeeds", async () => {
- vi.useFakeTimers();
-
- // First call fails with a transient error
- mockCreate.mockRejectedValueOnce(new Error("Network error"));
- // Second call succeeds
- mockSuccessResponse("Success after retry");
-
- const promise = generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO",
- "context",
- );
-
- // Fast-forward through retry delay
- await vi.runAllTimersAsync();
-
- const result = await promise;
-
- expect(result).toBe("Success after retry");
- expect(mockCreate).toHaveBeenCalledTimes(2);
-
- vi.useRealTimers();
- });
-
- it("throws after max retries exceeded", { timeout: 15000 }, async () => {
- // All calls fail - the retry has exponential backoff (1s, 2s, 4s)
- // so we need a longer timeout
- const error = new Error("Persistent network error");
- mockCreate.mockRejectedValue(error);
-
- await expect(
- generateRecommendation("test-api-key", "title-keyword", "SEO", "context"),
- ).rejects.toThrow("Persistent network error");
-
- // Initial + 2 retries = 3 total calls
- expect(mockCreate).toHaveBeenCalledTimes(3);
- });
-
- it("returns empty string when API returns null content", async () => {
- mockCreate.mockResolvedValueOnce({
- choices: [{ message: { content: null } }],
- });
-
- const result = await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO",
- "context",
- );
-
- expect(result).toBe("");
- });
-
- it("returns empty string when API returns undefined content", async () => {
- mockCreate.mockResolvedValueOnce({
- choices: [{ message: {} }],
- });
-
- const result = await generateRecommendation(
- "test-api-key",
- "title-keyword",
- "SEO",
- "context",
- );
-
- expect(result).toBe("");
- });
- });
-
- describe("generateH2Suggestion", () => {
- it("generates an H2 suggestion", async () => {
- mockSuccessResponse("Why SEO Tips Matter for Your Business");
-
- const result = await generateH2Suggestion(
- "test-api-key",
- "About Us",
- "SEO tips",
- );
-
- expect(result).toBe("Why SEO Tips Matter for Your Business");
- });
-
- it("includes keyword in the prompt", async () => {
- mockSuccessResponse("SEO Tips Section");
-
- await generateH2Suggestion("test-api-key", "About", "my keyword");
-
- const callArgs = mockCreate.mock.calls[0][0];
- const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user");
- expect(userMessage.content).toContain("my keyword");
- });
-
- it("includes advanced options when provided", async () => {
- mockSuccessResponse("Les Conseils SEO Essentiels");
-
- await generateH2Suggestion(
- "test-api-key",
- "About",
- "SEO tips",
- { languageCode: "fr", pageType: "blog" },
- );
-
- const callArgs = mockCreate.mock.calls[0][0];
- const systemMessage = callArgs.messages.find((m: { role: string }) => m.role === "system");
- expect(systemMessage.content).toContain("French");
- });
- });
-
- describe("generateAllH2Suggestions", () => {
- it("generates suggestions for all H2s", async () => {
- mockSuccessResponse("First SEO Tips Heading");
- mockSuccessResponse("Second SEO Tips Section");
- mockSuccessResponse("Third SEO Tips Guide");
-
- const result = await generateAllH2Suggestions(
- "test-api-key",
- ["Heading 1", "Heading 2", "Heading 3"],
- "SEO tips",
- );
-
- expect(result).toHaveLength(3);
- expect(result[0]).toBe("First SEO Tips Heading");
- expect(result[1]).toBe("Second SEO Tips Section");
- expect(result[2]).toBe("Third SEO Tips Guide");
- });
-
- it("handles empty H2 array", async () => {
- const result = await generateAllH2Suggestions(
- "test-api-key",
- [],
- "SEO tips",
- );
-
- expect(result).toEqual([]);
- expect(mockCreate).not.toHaveBeenCalled();
- });
-
- it("handles single H2", async () => {
- mockSuccessResponse("Single Heading About SEO Tips");
-
- const result = await generateAllH2Suggestions(
- "test-api-key",
- ["Original Heading"],
- "SEO tips",
- );
-
- expect(result).toHaveLength(1);
- expect(result[0]).toBe("Single Heading About SEO Tips");
- });
- });
-
- describe("generateAltText", () => {
- it("generates alt text for an image", async () => {
- mockSuccessResponse("Team discussing SEO tips in a modern office");
-
- const result = await generateAltText(
- "test-api-key",
- "https://example.com/images/team-meeting.jpg",
- "SEO tips",
- );
-
- expect(result).toBe("Team discussing SEO tips in a modern office");
- });
-
- it("extracts filename from image URL", async () => {
- mockSuccessResponse("Product screenshot showing SEO tips");
-
- await generateAltText(
- "test-api-key",
- "https://example.com/images/product-screenshot.png?v=123",
- "SEO tips",
- );
-
- const callArgs = mockCreate.mock.calls[0][0];
- const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user");
- expect(userMessage.content).toContain("product-screenshot.png");
- });
-
- it("handles URL without query params", async () => {
- mockSuccessResponse("Hero image alt text");
-
- await generateAltText(
- "test-api-key",
- "https://example.com/hero.webp",
- "keyword",
- );
-
- const callArgs = mockCreate.mock.calls[0][0];
- const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user");
- expect(userMessage.content).toContain("hero.webp");
- });
- });
-
- describe("legacy exports", () => {
- it("generateTitle calls generateRecommendation with title-keyword", async () => {
- mockSuccessResponse("Generated Title");
-
- const result = await generateTitle(
- "test-api-key",
- "keyword",
- "context",
- );
-
- expect(result).toBe("Generated Title");
- expect(mockCreate).toHaveBeenCalledOnce();
- });
-
- it("generateMetaDescription calls generateRecommendation with meta-description-keyword", async () => {
- mockSuccessResponse("Generated meta description");
-
- const result = await generateMetaDescription(
- "test-api-key",
- "keyword",
- "context",
- );
-
- expect(result).toBe("Generated meta description");
- expect(mockCreate).toHaveBeenCalledOnce();
- });
-
- it("legacy exports support advanced options", async () => {
- mockSuccessResponse("Title with options");
-
- await generateTitle(
- "test-api-key",
- "keyword",
- "context",
- { pageType: "blog" },
- );
-
- const callArgs = mockCreate.mock.calls[0][0];
- const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user");
- expect(userMessage.content).toContain("blog");
- });
- });
-});
diff --git a/app/src/lib/store.test.ts b/app/src/lib/store.test.ts
index 765772a..099e79c 100644
--- a/app/src/lib/store.test.ts
+++ b/app/src/lib/store.test.ts
@@ -76,12 +76,12 @@ describe("useStore", () => {
expect(useStore.getState().apiKey).toBe("sk-test-123");
expect(chrome.storage.local.set).toHaveBeenCalledWith({
- openai_api_key: "sk-test-123",
+ anthropic_api_key: "sk-test-123",
});
});
it("loadApiKey reads api key from storage and sets it", async () => {
- await chrome.storage.local.set({ openai_api_key: "sk-loaded" });
+ await chrome.storage.local.set({ anthropic_api_key: "sk-loaded" });
await useStore.getState().loadApiKey();
diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts
index 2c501bc..ff8a693 100644
--- a/app/src/lib/store.ts
+++ b/app/src/lib/store.ts
@@ -51,14 +51,14 @@ export const useStore = create((set) => ({
setActiveCategory: (category) =>
set({ activeCategory: category, view: category ? "subscores" : "score" }),
setApiKey: async (key) => {
- await setStorageItem("openai_api_key", key);
+ await setStorageItem("anthropic_api_key", key);
set({ apiKey: key });
},
setError: (error) => set({ error }),
showToast: (message) => set({ toast: { visible: true, message } }),
hideToast: () => set({ toast: { visible: false, message: "" } }),
loadApiKey: async () => {
- const key = await getStorageItem("openai_api_key");
+ const key = await getStorageItem("anthropic_api_key");
if (key) set({ apiKey: key });
const lang = await getStorageItem("default_language");
if (lang) set((state) => ({ settings: { ...state.settings, language: lang } }));
diff --git a/app/src/options/Options.test.tsx b/app/src/options/Options.test.tsx
index 9ded0f1..87e4884 100644
--- a/app/src/options/Options.test.tsx
+++ b/app/src/options/Options.test.tsx
@@ -6,7 +6,8 @@ import { useEntitlementStore } from "@/lib/entitlement-store";
import {
activate,
deactivate,
- getAiQuotaRemaining,
+ getFreeAiQuota,
+ getProAiRemaining,
getValidEntitlement,
hasStoredLicenseKey,
type EntitlementClaims,
@@ -20,16 +21,19 @@ vi.mock("@/lib/entitlement", async (importOriginal) => {
activate: vi.fn(),
deactivate: vi.fn(),
getValidEntitlement: vi.fn(),
- getAiQuotaRemaining: vi.fn(),
+ getFreeAiQuota: vi.fn(),
+ getProAiRemaining: vi.fn(),
hasStoredLicenseKey: vi.fn(),
- consumeAiQuota: vi.fn(),
+ recordProAiQuota: vi.fn(),
+ recordFreeAiQuota: vi.fn(),
};
});
const activateMock = vi.mocked(activate);
const deactivateMock = vi.mocked(deactivate);
const getValidEntitlementMock = vi.mocked(getValidEntitlement);
-const getAiQuotaRemainingMock = vi.mocked(getAiQuotaRemaining);
+const getFreeAiQuotaMock = vi.mocked(getFreeAiQuota);
+const getProAiRemainingMock = vi.mocked(getProAiRemaining);
const hasStoredLicenseKeyMock = vi.mocked(hasStoredLicenseKey);
const proClaims: EntitlementClaims = {
@@ -41,132 +45,133 @@ const proClaims: EntitlementClaims = {
exp: Math.floor(Date.now() / 1000) + 3600,
};
+function resetEntitlementState() {
+ useEntitlementStore.setState({
+ entitlementLoaded: false,
+ isPro: false,
+ tier: "free",
+ expiresAt: null,
+ quotaLimit: 0,
+ aiQuotaRemaining: 0,
+ freeAiRemaining: null,
+ freeAiLimit: null,
+ canUseAdvancedOptions: false,
+ canUseMultiLanguage: false,
+ canUseSchema: false,
+ canBringOwnKey: false,
+ hasLicenseKey: false,
+ activating: false,
+ activationError: null,
+ });
+}
+
+/** Makes hydrateEntitlement resolve to a Pro entitlement (all canUse* true). */
+function mockPro(remaining = 100) {
+ getValidEntitlementMock.mockResolvedValue(proClaims);
+ getProAiRemainingMock.mockResolvedValue(remaining);
+ hasStoredLicenseKeyMock.mockResolvedValue(true);
+}
+
describe("Options page", () => {
beforeEach(() => {
- // Reset chrome.storage mock between tests
- (chrome.storage.local.get as ReturnType).mockImplementation(
- () => Promise.resolve({}),
+ (chrome.storage.local.get as ReturnType).mockImplementation(() =>
+ Promise.resolve({}),
);
getValidEntitlementMock.mockResolvedValue(null);
- getAiQuotaRemainingMock.mockResolvedValue(0);
+ getFreeAiQuotaMock.mockResolvedValue(null);
+ getProAiRemainingMock.mockResolvedValue(0);
hasStoredLicenseKeyMock.mockResolvedValue(false);
- useEntitlementStore.setState({
- entitlementLoaded: false,
- isPro: false,
- tier: "free",
- expiresAt: null,
- quotaLimit: 0,
- aiQuotaRemaining: 0,
- canUseAdvancedOptions: false,
- hasLicenseKey: false,
- activating: false,
- activationError: null,
- });
+ resetEntitlementState();
});
// --- Rendering ---
it("renders the settings heading", () => {
render();
- expect(
- screen.getByRole("heading", { name: /settings/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument();
});
- it("renders the OpenAI API key field", () => {
+ it("renders the default language control and a save button", () => {
render();
- expect(screen.getByLabelText(/openai api key/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/default language/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});
- it("renders the default language select", () => {
- render();
- expect(
- screen.getByLabelText(/default language/i),
- ).toBeInTheDocument();
- });
+ // --- Free tier gating ---
- it("renders a save button", () => {
+ it("hides the Anthropic API key input for free users (Pro upsell instead)", async () => {
render();
+ await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument());
+
+ expect(screen.queryByLabelText(/anthropic api key/i)).not.toBeInTheDocument();
expect(
- screen.getByRole("button", { name: /save/i }),
+ screen.getByText(/bring your own anthropic key with optia pro/i),
).toBeInTheDocument();
});
- // --- Loading saved values ---
-
- it("loads saved API key from chrome.storage on mount", async () => {
- (chrome.storage.local.get as ReturnType).mockImplementation(
- () =>
- Promise.resolve({
- openai_api_key: "sk-test-key-123",
- default_language: "en",
- }),
+ it("disables the language select and pins English for free users", async () => {
+ (chrome.storage.local.get as ReturnType).mockImplementation(() =>
+ Promise.resolve({ default_language: "fr" }),
);
-
render();
+ await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument());
- await waitFor(() => {
- expect(screen.getByLabelText(/openai api key/i)).toHaveValue(
- "sk-test-key-123",
- );
- });
+ const select = screen.getByLabelText(/default language/i);
+ expect(select).toBeDisabled();
+ expect(select).toHaveValue("en");
});
- it("loads saved language from chrome.storage on mount", async () => {
- (chrome.storage.local.get as ReturnType).mockImplementation(
- () =>
- Promise.resolve({
- openai_api_key: "",
- default_language: "fr",
- }),
- );
-
+ it("saves only the language (English) for free users, never a key", async () => {
+ const user = userEvent.setup();
render();
+ await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument());
- await waitFor(() => {
- expect(screen.getByLabelText(/default language/i)).toHaveValue("fr");
- });
+ await user.click(screen.getByRole("button", { name: /save/i }));
+
+ expect(chrome.storage.local.set).toHaveBeenCalledWith({ default_language: "en" });
+ const call = (chrome.storage.local.set as ReturnType).mock.calls[0][0];
+ expect(call).not.toHaveProperty("anthropic_api_key");
});
- // --- User interactions ---
+ // --- Pro tier ---
- it("allows typing an API key", async () => {
- const user = userEvent.setup();
+ it("renders the Anthropic API key input (password) for Pro users", async () => {
+ mockPro();
render();
- const input = screen.getByLabelText(/openai api key/i);
- await user.clear(input);
- await user.type(input, "sk-new-key");
-
- expect(input).toHaveValue("sk-new-key");
+ await waitFor(() => {
+ expect(screen.getByLabelText(/anthropic api key/i)).toBeInTheDocument();
+ });
+ expect(screen.getByLabelText(/anthropic api key/i)).toHaveAttribute("type", "password");
+ expect(screen.getByLabelText(/default language/i)).toBeEnabled();
});
- it("allows selecting a language", async () => {
- const user = userEvent.setup();
+ it("loads a saved Anthropic key and language for Pro users", async () => {
+ mockPro();
+ (chrome.storage.local.get as ReturnType).mockImplementation(() =>
+ Promise.resolve({ anthropic_api_key: "sk-ant-123", default_language: "de" }),
+ );
render();
- const select = screen.getByLabelText(/default language/i);
- await user.selectOptions(select, "de");
-
- expect(select).toHaveValue("de");
+ await waitFor(() => {
+ expect(screen.getByLabelText(/anthropic api key/i)).toHaveValue("sk-ant-123");
+ });
+ expect(screen.getByLabelText(/default language/i)).toHaveValue("de");
});
- it("saves settings to chrome.storage when save is clicked", async () => {
+ it("saves the Anthropic key and language for Pro users", async () => {
+ mockPro();
const user = userEvent.setup();
render();
+ await waitFor(() => expect(screen.getByLabelText(/anthropic api key/i)).toBeInTheDocument());
- const input = screen.getByLabelText(/openai api key/i);
- await user.clear(input);
- await user.type(input, "sk-saved-key");
-
- const select = screen.getByLabelText(/default language/i);
- await user.selectOptions(select, "es");
-
+ await user.type(screen.getByLabelText(/anthropic api key/i), "sk-ant-saved");
+ await user.selectOptions(screen.getByLabelText(/default language/i), "es");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(chrome.storage.local.set).toHaveBeenCalledWith(
expect.objectContaining({
- openai_api_key: "sk-saved-key",
+ anthropic_api_key: "sk-ant-saved",
default_language: "es",
}),
);
@@ -175,29 +180,8 @@ describe("Options page", () => {
it("shows a success message after saving", async () => {
const user = userEvent.setup();
render();
-
await user.click(screen.getByRole("button", { name: /save/i }));
-
- await waitFor(() => {
- expect(screen.getByText(/saved/i)).toBeInTheDocument();
- });
- });
-
- // --- Accessibility ---
-
- it("API key input has password type for security", () => {
- render();
- expect(screen.getByLabelText(/openai api key/i)).toHaveAttribute(
- "type",
- "password",
- );
- });
-
- it("all form controls have associated labels", () => {
- render();
- // These will throw if no label is associated
- expect(screen.getByLabelText(/openai api key/i)).toBeInTheDocument();
- expect(screen.getByLabelText(/default language/i)).toBeInTheDocument();
+ await waitFor(() => expect(screen.getByText(/saved/i)).toBeInTheDocument());
});
// --- License card ---
@@ -206,9 +190,7 @@ describe("Options page", () => {
render();
expect(screen.getByRole("heading", { name: /license/i })).toBeInTheDocument();
- await waitFor(() => {
- expect(screen.getByText("Free")).toBeInTheDocument();
- });
+ await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument());
expect(screen.getByLabelText(/license key/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^activate$/i })).toBeDisabled();
});
@@ -216,9 +198,7 @@ describe("Options page", () => {
it("activates a license key and switches to the Pro state", async () => {
const user = userEvent.setup();
activateMock.mockImplementation(async () => {
- getValidEntitlementMock.mockResolvedValue(proClaims);
- getAiQuotaRemainingMock.mockResolvedValue(100);
- hasStoredLicenseKeyMock.mockResolvedValue(true);
+ mockPro(100);
return proClaims;
});
render();
@@ -227,9 +207,7 @@ describe("Options page", () => {
await user.click(screen.getByRole("button", { name: /^activate$/i }));
expect(activateMock).toHaveBeenCalledWith("optia_live_abc");
- await waitFor(() => {
- expect(screen.getByText("Pro")).toBeInTheDocument();
- });
+ await waitFor(() => expect(screen.getByText("Pro")).toBeInTheDocument());
expect(screen.getByText(/100 of 100 remaining/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /deactivate/i })).toBeInTheDocument();
});
@@ -250,21 +228,19 @@ describe("Options page", () => {
it("deactivates and returns to the free state", async () => {
const user = userEvent.setup();
- getValidEntitlementMock.mockResolvedValue(proClaims);
- getAiQuotaRemainingMock.mockResolvedValue(80);
- hasStoredLicenseKeyMock.mockResolvedValue(true);
- deactivateMock.mockResolvedValue();
+ mockPro(80);
+ deactivateMock.mockImplementation(async () => {
+ // After deactivation the entitlement is gone; next hydrate resolves free.
+ getValidEntitlementMock.mockResolvedValue(null);
+ hasStoredLicenseKeyMock.mockResolvedValue(false);
+ });
render();
- await waitFor(() => {
- expect(screen.getByText("Pro")).toBeInTheDocument();
- });
+ await waitFor(() => expect(screen.getByText("Pro")).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: /deactivate/i }));
expect(deactivateMock).toHaveBeenCalled();
- await waitFor(() => {
- expect(screen.getByText("Free")).toBeInTheDocument();
- });
+ await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument());
expect(screen.getByLabelText(/license key/i)).toBeInTheDocument();
});
});
diff --git a/app/src/options/Options.tsx b/app/src/options/Options.tsx
index 21dfe95..9984db3 100644
--- a/app/src/options/Options.tsx
+++ b/app/src/options/Options.tsx
@@ -76,7 +76,7 @@ function LicenseCard() {
) : (
- Enter your Optia Pro license key to unlock AI without an OpenAI key and advanced
+ Enter your Optia Pro license key to unlock AI without an Anthropic key and advanced
analysis.
@@ -116,24 +116,31 @@ const languages = SUPPORTED_LANGUAGES.map((lang) => ({
}));
export function Options() {
+ const canBringOwnKey = useEntitlementStore((s) => s.canBringOwnKey);
+ const canUseMultiLanguage = useEntitlementStore((s) => s.canUseMultiLanguage);
+ const hydrateEntitlement = useEntitlementStore((s) => s.hydrateEntitlement);
const [apiKey, setApiKey] = useState("");
const [language, setLanguage] = useState("en");
const [saved, setSaved] = useState(false);
useEffect(() => {
+ void hydrateEntitlement();
chrome.storage.local
- .get(["openai_api_key", "default_language"])
+ .get(["anthropic_api_key", "default_language"])
.then((result) => {
- if (result.openai_api_key) setApiKey(result.openai_api_key);
+ if (result.anthropic_api_key) setApiKey(result.anthropic_api_key);
if (result.default_language) setLanguage(result.default_language);
});
- }, []);
+ }, [hydrateEntitlement]);
+
+ // Multi-language is a Pro feature — free users are pinned to English.
+ const effectiveLanguage = canUseMultiLanguage ? language : "en";
const handleSave = async () => {
- await chrome.storage.local.set({
- openai_api_key: apiKey,
- default_language: language,
- });
+ const toStore: Record = { default_language: effectiveLanguage };
+ // BYO key is Pro-only; never persist a key for a free user.
+ if (canBringOwnKey) toStore.anthropic_api_key = apiKey;
+ await chrome.storage.local.set(toStore);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
@@ -149,37 +156,60 @@ export function Options() {
Settings
- Connect OpenAI to unlock AI-powered recommendations.
+ Free users get AI recommendations through Optia's hosted service. Activate Pro to bring
+ your own Anthropic key and unlock multi-language output.