From 2b927b51148617bbf704333f17083a892acb9ac2 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Fri, 21 Aug 2026 13:32:44 -0400 Subject: [PATCH] feat(options): AI model picker for hosted and BYO-key generation Client half of optia-backend#21. The options page gains an "AI model" select: with a working BYO key it lists the newest models available on the user's own Anthropic key (client.models.list); otherwise it lists the hosted allowlist from GET /ai/models ("included, no extra charge"). The selection persists per mode (byok_model / hosted_model), hydrates with the store, rides proxy requests as `model` (server-validated against the allowlist), and overrides the built-in default on the direct path. Co-Authored-By: Claude Fable 5 --- app/src/lib/ai-proxy.ts | 21 +++++++++++- app/src/lib/ai.test.ts | 12 +++++++ app/src/lib/ai.ts | 2 ++ app/src/lib/anthropic.ts | 17 ++++++++- app/src/lib/store.test.ts | 11 ++++++ app/src/lib/store.ts | 19 ++++++++++ app/src/options/Options.test.tsx | 38 +++++++++++++++++++- app/src/options/Options.tsx | 59 ++++++++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 3 deletions(-) diff --git a/app/src/lib/ai-proxy.ts b/app/src/lib/ai-proxy.ts index 5a6160e..8a1cad2 100644 --- a/app/src/lib/ai-proxy.ts +++ b/app/src/lib/ai-proxy.ts @@ -51,10 +51,28 @@ export interface ProxyRequest { context: string; /** When true, authenticate as Pro via the stored entitlement; else free-tier by install id. */ authenticated: boolean; + /** Caller-selected Claude model; must be on the server's allowlist (GET /ai/models). */ + model?: string; /** Sent only when the entitlement is actually presented (Pro-only features). */ advanced?: ProxyAdvancedOptions; } +export interface ProxyModels { + models: string[]; + default: string; +} + +/** The hosted-generation model menu (server allowlist). */ +export async function fetchProxyModels(): Promise { + const response = await fetch(`${BACKEND_BASE_URL}/ai/models`); + if (!response.ok) throw new AiProxyError("upstream", "Could not load the model list."); + const body = (await response.json().catch(() => null)) as Partial | null; + if (!body || !Array.isArray(body.models) || typeof body.default !== "string") { + throw new AiProxyError("upstream", "The AI service returned an unexpected model list."); + } + return { models: body.models, default: body.default }; +} + interface ErrorBody { error?: { code?: string; message?: string }; code?: string; @@ -91,7 +109,7 @@ function mapError(status: number, code: string, message: string): AiProxyError { /** POSTs one generation to the hosted proxy. Throws AiProxyError on failure. */ export async function generateViaProxy(request: ProxyRequest): Promise { - const { checkId, keyword, context, authenticated, advanced } = request; + const { checkId, keyword, context, authenticated, model, advanced } = 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. @@ -117,6 +135,7 @@ export async function generateViaProxy(request: ProxyRequest): Promise { expect(generateRecommendationDirectMock).not.toHaveBeenCalled(); }); + it("attaches the selected hosted model to the proxy request", async () => { + setMode("free"); + useStore.setState({ hostedModel: "claude-sonnet-5" }); + + await generateRecommendation("title-keyword", "kw", "ctx"); + + expect(generateViaProxyMock).toHaveBeenCalledWith( + expect.objectContaining({ model: "claude-sonnet-5" }), + ); + useStore.setState({ hostedModel: null }); + }); + it("free → advanced options never reach the proxy even if passed", async () => { setMode("free"); diff --git a/app/src/lib/ai.ts b/app/src/lib/ai.ts index e4d5b34..55a75c2 100644 --- a/app/src/lib/ai.ts +++ b/app/src/lib/ai.ts @@ -98,11 +98,13 @@ async function runProxy( isRetry = false, ): Promise { try { + const hostedModel = useStore.getState().hostedModel; const result = await generateViaProxy({ checkId, keyword, context, authenticated, + ...(hostedModel ? { model: hostedModel } : {}), ...(authenticated && advancedOptions ? { advanced: { diff --git a/app/src/lib/anthropic.ts b/app/src/lib/anthropic.ts index 5e76dbd..d4c026f 100644 --- a/app/src/lib/anthropic.ts +++ b/app/src/lib/anthropic.ts @@ -1,5 +1,6 @@ import Anthropic from "@anthropic-ai/sdk"; import { getLanguageByCode } from "./languages"; +import { useStore } from "@/lib/store"; // 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). @@ -28,6 +29,17 @@ function extractText(message: Anthropic.Message): string { return text.replace(/^["']|["']$/g, ""); } +/** + * The newest N models available on the user's own key (optia-backend#21). + * The Anthropic list endpoint returns newest-first; throws on a bad key or + * network failure so the caller can fall back to the built-in default. + */ +export async function listTopModels(apiKey: string, limit = 3): Promise { + const client = createClient(apiKey); + const page = await client.models.list({ limit }); + return page.data.map((m) => m.id); +} + async function completeWithRetry( apiKey: string, systemPrompt: string, @@ -36,11 +48,14 @@ async function completeWithRetry( ): Promise { const client = createClient(apiKey); let retries = 0; + // Model choice (optia-backend#21): the user's selected BYOK model, read at + // call time; null falls back to the built-in default. + const model = useStore.getState().byokModel ?? AI_MODEL; while (retries <= maxRetries) { try { const message = await client.messages.create({ - model: AI_MODEL, + model, max_tokens: MAX_TOKENS, system: systemPrompt, messages: [{ role: "user", content: userPrompt }], diff --git a/app/src/lib/store.test.ts b/app/src/lib/store.test.ts index 0d5a373..1e2338f 100644 --- a/app/src/lib/store.test.ts +++ b/app/src/lib/store.test.ts @@ -90,6 +90,17 @@ describe("useStore", () => { expect(useStore.getState().apiKey).toBe("sk-loaded"); }); + it("persists and hydrates the per-mode model selections", async () => { + await useStore.getState().setHostedModel("claude-sonnet-5"); + await useStore.getState().setByokModel("claude-opus-5"); + useStore.setState({ hostedModel: null, byokModel: null }); + + await useStore.getState().loadApiKey(); + + expect(useStore.getState().hostedModel).toBe("claude-sonnet-5"); + expect(useStore.getState().byokModel).toBe("claude-opus-5"); + }); + it("setApiKey clears a prior key rejection", async () => { useStore.setState({ apiKeyInvalid: true }); diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts index 6f6bbbb..c7b2c0f 100644 --- a/app/src/lib/store.ts +++ b/app/src/lib/store.ts @@ -18,12 +18,18 @@ interface Store extends AppState { useOwnKey: boolean; /** Session-scoped: the stored key was rejected by Anthropic (401/403). Never persisted. */ apiKeyInvalid: boolean; + /** Selected Claude model for hosted (proxy) generation; null = server default. */ + hostedModel: string | null; + /** Selected Claude model for BYO-key direct generation; null = built-in default. */ + byokModel: string | null; setView: (view: AppState["view"]) => void; setAnalysis: (analysis: SEOAnalysis) => void; setSettings: (settings: Partial) => void; setActiveCategory: (category: CheckCategory | null) => void; setApiKey: (key: string) => void; setUseOwnKey: (value: boolean) => Promise; + setHostedModel: (model: string | null) => Promise; + setByokModel: (model: string | null) => Promise; setApiKeyInvalid: (value: boolean) => void; setError: (error: string | null) => void; showToast: (message: string) => void; @@ -49,6 +55,8 @@ export const useStore = create((set) => ({ apiKey: "", useOwnKey: true, apiKeyInvalid: false, + hostedModel: null, + byokModel: null, error: null, toast: { visible: false, message: "" }, @@ -68,6 +76,14 @@ export const useStore = create((set) => ({ // Turning the toggle is a deliberate retry — clear any prior rejection. set({ useOwnKey: value, apiKeyInvalid: false }); }, + setHostedModel: async (model) => { + await setStorageItem("hosted_model", model); + set({ hostedModel: model }); + }, + setByokModel: async (model) => { + await setStorageItem("byok_model", model); + set({ byokModel: model }); + }, setApiKeyInvalid: (value) => set({ apiKeyInvalid: value }), setError: (error) => set({ error }), showToast: (message) => set({ toast: { visible: true, message } }), @@ -80,6 +96,9 @@ export const useStore = create((set) => ({ set({ useOwnKey: useOwn !== false }); const lang = await getStorageItem("default_language"); if (lang) set((state) => ({ settings: { ...state.settings, language: lang } })); + const hostedModel = await getStorageItem("hosted_model"); + const byokModel = await getStorageItem("byok_model"); + set({ hostedModel: hostedModel ?? null, byokModel: byokModel ?? null }); }, reset: () => set((state) => ({ diff --git a/app/src/options/Options.test.tsx b/app/src/options/Options.test.tsx index c5ed0f1..4501970 100644 --- a/app/src/options/Options.test.tsx +++ b/app/src/options/Options.test.tsx @@ -32,6 +32,21 @@ vi.mock("@/lib/entitlement", async (importOriginal) => { }; }); +// The model pickers hit the network (Anthropic / the proxy); mock both lists. +vi.mock("@/lib/anthropic", () => ({ + listTopModels: vi.fn().mockResolvedValue(["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"]), +})); +vi.mock("@/lib/ai-proxy", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + fetchProxyModels: vi.fn().mockResolvedValue({ + models: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-5"], + default: "claude-haiku-4-5", + }), + }; +}); + const activateMock = vi.mocked(activate); const deactivateMock = vi.mocked(deactivate); const getBillingPortalUrlMock = vi.mocked(getBillingPortalUrl); @@ -106,6 +121,25 @@ describe("Options page", () => { expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); }); + // --- Model choice (optia-backend#21) --- + + it("lists the hosted model menu from the proxy and saves the selection", async () => { + const user = userEvent.setup(); + render(); + const select = await screen.findByLabelText(/ai model/i); + expect(select).toBeInTheDocument(); + expect(screen.getByText(/no extra charge/i)).toBeInTheDocument(); + + await user.selectOptions(select, "claude-sonnet-5"); + await user.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ hosted_model: "claude-sonnet-5" }), + ); + }); + }); + // --- Free tier gating --- it("hides the Anthropic API key input for free users (Pro upsell instead)", async () => { @@ -137,7 +171,9 @@ describe("Options page", () => { await user.click(screen.getByRole("button", { name: /save/i })); - expect(chrome.storage.local.set).toHaveBeenCalledWith({ default_language: "en" }); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ default_language: "en" }), + ); const call = (chrome.storage.local.set as ReturnType).mock.calls[0][0]; expect(call).not.toHaveProperty("anthropic_api_key"); }); diff --git a/app/src/options/Options.tsx b/app/src/options/Options.tsx index f6a1cd4..6bd59d6 100644 --- a/app/src/options/Options.tsx +++ b/app/src/options/Options.tsx @@ -6,6 +6,8 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle"; import { Toggle } from "@/components/ui/Toggle"; import { useAiStatus, useEntitlementStore } from "@/lib/entitlement-store"; import { useStore } from "@/lib/store"; +import { listTopModels } from "@/lib/anthropic"; +import { fetchProxyModels } from "@/lib/ai-proxy"; /** Opens an external URL in a new tab (extension page or dev preview). */ function openExternalUrl(url: string) { @@ -173,6 +175,34 @@ export function Options() { const [useOwnKey, setUseOwnKey] = useState(true); const [language, setLanguage] = useState("en"); const [saved, setSaved] = useState(false); + const [modelOptions, setModelOptions] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + + // Model choice (optia-backend#21). BYOK mode lists the newest models on the + // user's own key; hosted mode lists the server's allowlist. The selection is + // stored per mode (byok_model / hosted_model). + const byokActive = canBringOwnKey && useOwnKey && apiKey.startsWith("sk-ant-") && apiKey.length > 40; + useEffect(() => { + let cancelled = false; + (async () => { + try { + const models = byokActive ? await listTopModels(apiKey) : (await fetchProxyModels()).models; + const storageKey = byokActive ? "byok_model" : "hosted_model"; + const stored = (await chrome.storage.local.get(storageKey))[storageKey] as + | string + | undefined; + if (cancelled) return; + setModelOptions(models); + setSelectedModel(stored && models.includes(stored) ? stored : (models[0] ?? "")); + } catch { + // Bad key or offline — hide the picker rather than show a broken one. + if (!cancelled) setModelOptions([]); + } + })(); + return () => { + cancelled = true; + }; + }, [byokActive, apiKey]); useEffect(() => { void hydrateEntitlement(); @@ -195,6 +225,7 @@ export function Options() { 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; + if (selectedModel) toStore[byokActive ? "byok_model" : "hosted_model"] = selectedModel; await chrome.storage.local.set(toStore); setSaved(true); setTimeout(() => setSaved(false), 2000); @@ -299,6 +330,34 @@ export function Options() { )} + {modelOptions.length > 0 && ( +
+ +
+ + +
+

+ {byokActive + ? "The newest models available on your Anthropic key." + : "Models included with Optia's hosted AI at no extra charge."} +

+
+ )} +