- 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
109 lines
4.1 KiB
TypeScript
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))];
|
|
}
|