Files
names/src/lib/ai.ts
tonym c307a75880 feat: platform AI names behind Sign in with OTM (v0.3.0)
- OTM SSO: /api/auth/otm-sso verifies the control-plane ticket (vendored
  verifier), in-memory jti claim, mints an HMAC cookie (src/lib/session.ts);
  /api/auth/session + /api/auth/signout. No accounts, no DB.
- /api/ai-names: server-side call to the platform's metered gateway
  (ANTHROPIC_BASE_URL + per-app gateway token), 401 without a session,
  ~20 req/h per OTM account. Prompt/parser shared with the BYO-key path
  via src/lib/ai-prompt.ts.
- Finder: 'Sign in with OTM for AI names' link → 'AI names' toggle when
  signed in; own key still overrides.

Pairs with platform 0.116.0 (needsAnthropic + needsAuthSecret on names).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
2026-08-16 18:27:38 -05:00

109 lines
4.1 KiB
TypeScript

// Optional AI name generation, using the VISITOR'S OWN API key. (The other AI
// path — the platform's metered gateway for signed-in OTM accounts — is
// src/lib/platform-ai.ts + app/api/ai-names; both share src/lib/ai-prompt.ts.)
//
// The key lives in this browser's localStorage and the request goes straight
// from the browser to Anthropic/Google. It never touches our server, never
// appears in our logs, and we never hold a metered credential for a free public
// tool. Same pattern rpo uses for its Google Vision key.
//
// Anthropic normally refuses browser-origin calls to protect people from
// shipping a secret key in a web bundle; `anthropic-dangerous-direct-browser-
// access` opts out. That header is correct HERE and would be wrong in a product
// that owns the key — the distinction is whose key it is. The user pasted their
// own, knowingly, into their own browser.
import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt";
export type AiProvider = "anthropic" | "gemini";
export interface AiSettings {
provider: AiProvider;
key: string;
}
const STORAGE_KEY = "names.aiKey";
export function loadAiSettings(): AiSettings | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AiSettings>;
if (!parsed.key || (parsed.provider !== "anthropic" && parsed.provider !== "gemini")) return null;
return { provider: parsed.provider, key: parsed.key };
} catch {
return null;
}
}
export function saveAiSettings(settings: AiSettings | null): void {
if (typeof window === "undefined") return;
if (!settings) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
async function callAnthropic(key: string, prompt: string): Promise<string> {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": key,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model: "claude-sonnet-5",
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Anthropic returned ${res.status}. ${detail.slice(0, 200)}`);
}
const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
return (body.content ?? []).map((b) => (b.type === "text" ? (b.text ?? "") : "")).join("");
}
async function callGemini(key: string, prompt: string): Promise<string> {
const url =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" +
encodeURIComponent(key);
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { temperature: 1, maxOutputTokens: 1024 },
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Gemini returned ${res.status}. ${detail.slice(0, 200)}`);
}
const body = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
};
return (body.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join("");
}
/** Ask the visitor's chosen model for names. Throws with a readable message on
* a bad key, a rate limit, or a network failure — the UI shows it and falls
* back to the built-in generator. */
export async function generateWithAi(
settings: AiSettings,
keywords: string[],
style: string,
count = 24,
): Promise<string[]> {
const prompt = userPrompt(keywords, style, count);
const text =
settings.provider === "anthropic"
? await callAnthropic(settings.key, prompt)
: await callGemini(settings.key, prompt);
return [...new Set(parseNames(text))];
}