Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/src/lib/ai-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyModels> {
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<ProxyModels> | 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;
Expand Down Expand Up @@ -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<ProxyResult> {
const { checkId, keyword, context, authenticated, advanced } = request;
const { checkId, keyword, context, authenticated, model, advanced } = request;
const headers: Record<string, string> = { "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.
Expand All @@ -117,6 +135,7 @@ export async function generateViaProxy(request: ProxyRequest): Promise<ProxyResu
keyword,
context,
installId,
...(model ? { model } : {}),
...(didAuthenticate && advanced?.languageCode
? { languageCode: advanced.languageCode }
: {}),
Expand Down
12 changes: 12 additions & 0 deletions app/src/lib/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ describe("generateRecommendation", () => {
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");

Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,13 @@ async function runProxy(
isRetry = false,
): Promise<string> {
try {
const hostedModel = useStore.getState().hostedModel;
const result = await generateViaProxy({
checkId,
keyword,
context,
authenticated,
...(hostedModel ? { model: hostedModel } : {}),
...(authenticated && advancedOptions
? {
advanced: {
Expand Down
17 changes: 16 additions & 1 deletion app/src/lib/anthropic.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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<string[]> {
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,
Expand All @@ -36,11 +48,14 @@ async function completeWithRetry(
): Promise<string> {
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 }],
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
19 changes: 19 additions & 0 deletions app/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalysisSettings>) => void;
setActiveCategory: (category: CheckCategory | null) => void;
setApiKey: (key: string) => void;
setUseOwnKey: (value: boolean) => Promise<void>;
setHostedModel: (model: string | null) => Promise<void>;
setByokModel: (model: string | null) => Promise<void>;
setApiKeyInvalid: (value: boolean) => void;
setError: (error: string | null) => void;
showToast: (message: string) => void;
Expand All @@ -49,6 +55,8 @@ export const useStore = create<Store>((set) => ({
apiKey: "",
useOwnKey: true,
apiKeyInvalid: false,
hostedModel: null,
byokModel: null,
error: null,
toast: { visible: false, message: "" },

Expand All @@ -68,6 +76,14 @@ export const useStore = create<Store>((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 } }),
Expand All @@ -80,6 +96,9 @@ export const useStore = create<Store>((set) => ({
set({ useOwnKey: useOwn !== false });
const lang = await getStorageItem<string>("default_language");
if (lang) set((state) => ({ settings: { ...state.settings, language: lang } }));
const hostedModel = await getStorageItem<string>("hosted_model");
const byokModel = await getStorageItem<string>("byok_model");
set({ hostedModel: hostedModel ?? null, byokModel: byokModel ?? null });
},
reset: () =>
set((state) => ({
Expand Down
38 changes: 37 additions & 1 deletion app/src/options/Options.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@/lib/ai-proxy")>();
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);
Expand Down Expand Up @@ -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(<Options />);
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 () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mock.calls[0][0];
expect(call).not.toHaveProperty("anthropic_api_key");
});
Expand Down
59 changes: 59 additions & 0 deletions app/src/options/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string[]>([]);
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();
Expand All @@ -195,6 +225,7 @@ export function Options() {
const toStore: Record<string, string> = { 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);
Expand Down Expand Up @@ -299,6 +330,34 @@ export function Options() {
)}
</div>

{modelOptions.length > 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="ai-model" className="text-body-semibold text-ink">
AI model
</label>
<div className="relative">
<select
id="ai-model"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full appearance-none rounded-input border border-border bg-surface px-3.5 py-3 pr-10 text-body text-ink shadow-card outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
>
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted" />
</div>
<p className="text-body-12 text-muted">
{byokActive
? "The newest models available on your Anthropic key."
: "Models included with Optia's hosted AI at no extra charge."}
</p>
</div>
)}

<button
onClick={handleSave}
className="self-start rounded-pill bg-brand px-6 py-2.5 text-button text-brand-fg shadow-brand transition-colors hover:bg-brand-hover"
Expand Down
Loading