eldouma-graphics / src /lib /aiKeys.ts
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
5.77 kB
// "Bring your own AI key" — the app works with NO server-side AI key: the visitor enters their own key
// here, it's kept ONLY in this browser's localStorage, and rides along with every AI request as headers.
// The server prefers a header key and falls back to its env key. Keys are never sent anywhere but the API,
// never logged, never persisted server-side. This is the single source of truth for the client side.
import { useEffect, useState } from "react";
// providers the modal offers. "custom" is any OpenAI-compatible endpoint (user supplies base URL + model).
export interface AiProvider { id: string; label: string; blurb: string; placeholder: string; keyUrl?: string; custom?: boolean }
export const AI_PROVIDERS: AiProvider[] = [
{ id: "kimi", label: "Kimi (Moonshot)", blurb: "Reliable tool-calling chat — the app's primary model.", placeholder: "sk-…", keyUrl: "https://platform.moonshot.ai/console/api-keys" },
{ id: "deepinfra", label: "DeepInfra", blurb: "Cheapest path — open Llama / Qwen, pay-per-token.", placeholder: "…", keyUrl: "https://deepinfra.com/dash/api_keys" },
{ id: "groq", label: "Groq", blurb: "Fast chat + Whisper speech-to-text. Has a free tier.", placeholder: "gsk_…", keyUrl: "https://console.groq.com/keys" },
{ id: "custom", label: "Custom (OpenAI-compatible)", blurb: "Any OpenAI-compatible base URL + model id.", placeholder: "sk-…", custom: true },
];
const LS_KEYS = "eldouma.aiKeys"; // JSON map provider→key
const LS_PROVIDER = "eldouma.aiProvider"; // the selected/active provider
const LS_CUSTOM = "eldouma.aiCustom"; // { baseUrl, model } for the custom endpoint
export const AI_KEYS_EVENT = "eldouma:ai-keys"; // fired whenever the config changes
export interface AiConfig { keys: Record<string, string>; provider: string; baseUrl: string; model: string }
const safeParse = <T,>(s: string | null, fallback: T): T => { try { return s ? (JSON.parse(s) as T) : fallback; } catch { return fallback; } };
export function getAiConfig(): AiConfig {
if (typeof localStorage === "undefined") return { keys: {}, provider: "", baseUrl: "", model: "" };
const keys = safeParse<Record<string, string>>(localStorage.getItem(LS_KEYS), {});
const custom = safeParse<{ baseUrl?: string; model?: string }>(localStorage.getItem(LS_CUSTOM), {});
return { keys: keys || {}, provider: localStorage.getItem(LS_PROVIDER) || "", baseUrl: custom.baseUrl || "", model: custom.model || "" };
}
// persist a change and broadcast it so every open AI surface re-reads (headers, switcher, banner)
export function saveAiConfig(next: { provider?: string; key?: string; baseUrl?: string; model?: string }): void {
if (typeof localStorage === "undefined") return;
const cfg = getAiConfig();
const provider = next.provider ?? cfg.provider;
const keys = { ...cfg.keys };
if (next.provider != null && typeof next.key === "string") {
const k = next.key.trim();
if (k) keys[next.provider] = k; else delete keys[next.provider];
}
localStorage.setItem(LS_KEYS, JSON.stringify(keys));
localStorage.setItem(LS_PROVIDER, provider);
localStorage.setItem(LS_CUSTOM, JSON.stringify({ baseUrl: (next.baseUrl ?? cfg.baseUrl).trim(), model: (next.model ?? cfg.model).trim() }));
window.dispatchEvent(new Event(AI_KEYS_EVENT));
}
// forget the key for one provider (does not change the selection unless it was the only key)
export function clearProviderKey(provider: string): void {
if (typeof localStorage === "undefined") return;
const cfg = getAiConfig();
const keys = { ...cfg.keys };
delete keys[provider];
localStorage.setItem(LS_KEYS, JSON.stringify(keys));
window.dispatchEvent(new Event(AI_KEYS_EVENT));
}
// the headers to attach to EVERY AI request. Empty when the user hasn't connected a key → the server
// then falls back to its own env key (if any). Centralized here so all call sites stay consistent.
export function aiHeaders(): Record<string, string> {
const cfg = getAiConfig();
const provider = cfg.provider;
if (!provider) return {};
const key = (cfg.keys[provider] || "").trim();
if (!key) return {};
const h: Record<string, string> = { "x-ai-key": key, "x-ai-provider": provider };
if (provider === "custom") {
if (cfg.baseUrl.trim()) h["x-ai-base-url"] = cfg.baseUrl.trim();
if (cfg.model.trim()) h["x-ai-model"] = cfg.model.trim();
}
return h;
}
// does the user have a usable key configured right now? (custom also needs a base URL)
export function hasUserKey(): boolean {
const h = aiHeaders();
return !!h["x-ai-key"] && (h["x-ai-provider"] !== "custom" || !!h["x-ai-base-url"]);
}
export interface ModelInfo { id: string; label: string; role: string; reasoning?: boolean; tools?: boolean }
// shared hook: the models the server reports as usable for THIS request (env keys + the user's own key).
// Re-fetches whenever the key config changes, so connecting a key instantly lights up the switcher/banner.
export function useAiModels(): { models: ModelInfo[]; defaults: Record<string, string>; loaded: boolean } {
const [state, setState] = useState<{ models: ModelInfo[]; defaults: Record<string, string>; loaded: boolean }>({ models: [], defaults: {}, loaded: false });
useEffect(() => {
let alive = true;
const load = () =>
fetch("/api/models", { headers: aiHeaders() })
.then((r) => r.json())
.then((d) => { if (alive) setState({ models: (d.models as ModelInfo[]) || [], defaults: d.defaults || {}, loaded: true }); })
.catch(() => { if (alive) setState((s) => ({ ...s, loaded: true })); });
load();
window.addEventListener(AI_KEYS_EVENT, load);
return () => { alive = false; window.removeEventListener(AI_KEYS_EVENT, load); };
}, []);
return state;
}