feat(keys): free-form model-provider keys — pi-ai provider table on /api/keys
Provider-keys interlude (owner spec, 2026-09-19): any pi-compatible
provider's key is manageable, not just the curated set.
GET /api/keys now also returns providers: {id, envVar, label}[] — every
pi-ai provider that takes a plain api-key env var (33 today), derived
from pi-ai's own provider→env-var table. No-drift mechanism: the table
is generated from pi-ai (builtinProviders + findEnvKeys over a
truthy-for-every-var proxy env) and pinned by
tests/auth-provider-keys.test.ts, which re-derives the truth at test
time and asserts EXACT equality — a pi-ai upgrade fails the gate loudly.
Owner-spec exclusions: github-copilot (OAuth), amazon-bedrock (AWS),
google-vertex (ADC), azure-openai-responses (base URL), the cloudflare
pair; anthropic keeps ANTHROPIC_API_KEY only. OPENROUTER_API_KEY
graduates from the curated catalog to the provider table (dedupe — the
UI pins it always-visible); curated stays Deepgram + the Google search
pair. Statuses: curated + present vars + unknown file vars — absent
provider keys never spam rows; the dropdown is discovery.
Write side: PUT/DELETE accept provider env vars (generic truncation
guard — trim, no whitespace, ≥12; family checks stay on the curated
three). Present-in-env provider vars surface as status rows so
env-wins/shadow semantics stay honest.
This commit is contained in:
+88
-14
@@ -30,12 +30,6 @@ export interface AuthKeyInfo {
|
||||
}
|
||||
|
||||
export const AUTH_KEYS: readonly AuthKeyInfo[] = [
|
||||
{
|
||||
id: "OPENROUTER_API_KEY",
|
||||
label: "OpenRouter",
|
||||
description: "The main model key — every teacher, prober and sub-agent call goes through it.",
|
||||
docsUrl: "https://openrouter.ai/settings/keys",
|
||||
},
|
||||
{
|
||||
id: "DEEPGRAM_API_KEY",
|
||||
label: "Deepgram",
|
||||
@@ -62,6 +56,80 @@ export function isKnownAuthKey(id: string): boolean {
|
||||
return AUTH_KEY_IDS.has(id);
|
||||
}
|
||||
|
||||
/** One model-provider key row for the settings page's dropdown: which
|
||||
* pi-ai provider, the env var its key rides in, and the human label
|
||||
* (pi's own Provider.name). */
|
||||
export interface AuthProviderInfo {
|
||||
id: string;
|
||||
envVar: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE MODEL-PROVIDER KEY TABLE (provider-keys interlude, 2026-09-19):
|
||||
* every pi-ai provider that takes a PLAIN api-key env var — derived from
|
||||
* pi-ai's own provider→env-var table at generation time, so it can never
|
||||
* drift from what pi actually reads: tests/auth-provider-keys.test.ts
|
||||
* re-derives the truth from pi-ai (builtinProviders + findEnvKeys via a
|
||||
* truthy-for-every-var proxy env) and asserts EXACT equality with this
|
||||
* table — a pi-ai upgrade that adds/renames a provider key fails the gate
|
||||
* loudly instead of drifting silently.
|
||||
*
|
||||
* EXCLUDED by owner spec — providers whose auth is not a plain key this
|
||||
* wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex
|
||||
* (ADC), azure-openai-responses (needs a base URL), the cloudflare
|
||||
* account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its
|
||||
* AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes
|
||||
* no env-key (naturally absent). Providers sharing one env var (moonshotai
|
||||
* / moonshotai-cn, opencode / opencode-go, the qwen-token-plan pair,
|
||||
* xiaomi variants) each keep their own row — the UI groups as it wishes.
|
||||
*/
|
||||
export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
|
||||
{ id: "ant-ling", envVar: "ANT_LING_API_KEY", label: "Ant Ling" },
|
||||
{ id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
|
||||
{ id: "baseten", envVar: "BASETEN_API_KEY", label: "Baseten" },
|
||||
{ id: "cerebras", envVar: "CEREBRAS_API_KEY", label: "Cerebras" },
|
||||
{ id: "deepseek", envVar: "DEEPSEEK_API_KEY", label: "DeepSeek" },
|
||||
{ id: "fireworks", envVar: "FIREWORKS_API_KEY", label: "Fireworks" },
|
||||
{ id: "google", envVar: "GEMINI_API_KEY", label: "Google" },
|
||||
{ id: "groq", envVar: "GROQ_API_KEY", label: "Groq" },
|
||||
{ id: "huggingface", envVar: "HF_TOKEN", label: "Hugging Face" },
|
||||
{ id: "kimi-coding", envVar: "KIMI_API_KEY", label: "Kimi For Coding" },
|
||||
{ id: "minimax", envVar: "MINIMAX_API_KEY", label: "MiniMax" },
|
||||
{ id: "minimax-cn", envVar: "MINIMAX_CN_API_KEY", label: "MiniMax CN" },
|
||||
{ id: "mistral", envVar: "MISTRAL_API_KEY", label: "Mistral" },
|
||||
{ id: "moonshotai", envVar: "MOONSHOT_API_KEY", label: "Moonshot AI" },
|
||||
{ id: "moonshotai-cn", envVar: "MOONSHOT_API_KEY", label: "Moonshot AI CN" },
|
||||
{ id: "nvidia", envVar: "NVIDIA_API_KEY", label: "NVIDIA" },
|
||||
{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" },
|
||||
{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" },
|
||||
{ id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" },
|
||||
{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" },
|
||||
{ id: "qwen-token-plan", envVar: "QWEN_TOKEN_PLAN_API_KEY", label: "Qwen Token Plan" },
|
||||
{ id: "qwen-token-plan-cn", envVar: "QWEN_TOKEN_PLAN_CN_API_KEY", label: "Qwen Token Plan CN" },
|
||||
{ id: "qwen-token-plan-individual", envVar: "QWEN_TOKEN_PLAN_API_KEY", label: "Qwen Token Plan Individual" },
|
||||
{ id: "radius", envVar: "RADIUS_API_KEY", label: "Radius" },
|
||||
{ id: "together", envVar: "TOGETHER_API_KEY", label: "Together" },
|
||||
{ id: "vercel-ai-gateway", envVar: "AI_GATEWAY_API_KEY", label: "Vercel AI Gateway" },
|
||||
{ id: "xai", envVar: "XAI_API_KEY", label: "xAI" },
|
||||
{ id: "xiaomi", envVar: "XIAOMI_API_KEY", label: "Xiaomi" },
|
||||
{ id: "xiaomi-token-plan-ams", envVar: "XIAOMI_TOKEN_PLAN_AMS_API_KEY", label: "Xiaomi Token Plan AMS" },
|
||||
{ id: "xiaomi-token-plan-cn", envVar: "XIAOMI_TOKEN_PLAN_CN_API_KEY", label: "Xiaomi Token Plan CN" },
|
||||
{ id: "xiaomi-token-plan-sgp", envVar: "XIAOMI_TOKEN_PLAN_SGP_API_KEY", label: "Xiaomi Token Plan SGP" },
|
||||
{ id: "zai", envVar: "ZAI_API_KEY", label: "Z.AI" },
|
||||
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
|
||||
] as const;
|
||||
|
||||
/** Every distinct env var a model-provider key rides in (the write side's
|
||||
* writability check — see isProviderEnvVar below; also drives which
|
||||
* PRESENT provider vars surface as status rows). */
|
||||
export const AUTH_PROVIDER_ENV_VARS: ReadonlySet<string> = new Set(AUTH_PROVIDER_KEYS.map((p) => p.envVar));
|
||||
|
||||
/** Is this env-var name the key of SOME pi-ai model provider? */
|
||||
export function isProviderEnvVar(envVar: string): boolean {
|
||||
return AUTH_PROVIDER_ENV_VARS.has(envVar);
|
||||
}
|
||||
|
||||
/** Shape-check result: ok, or a 400-ready message naming the expectation. */
|
||||
export type KeyShapeResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
@@ -70,10 +138,12 @@ export type KeyShapeResult = { ok: true } | { ok: false; error: string };
|
||||
* a truncated or wrong-family key can never land in the store). Values are
|
||||
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
||||
*
|
||||
* Families: OpenRouter keys are `sk-or-…`; Google API keys are `AIza…`;
|
||||
* Programmable Search Engine IDs are either the legacy `digits:digits`
|
||||
* form or a plain alphanumeric token; Deepgram publishes no key format, so
|
||||
* the check is a truncation guard (non-empty, ≥ 20 chars, no whitespace).
|
||||
* CURATED keys carry family rules (Google AIza…, the CSE forms,
|
||||
* Deepgram's length floor — a truncation guard; it publishes no format).
|
||||
* MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families
|
||||
* vary by provider — only paste hygiene applies; owner spec: min length
|
||||
* ~12, no whitespace). OPENROUTER_API_KEY moved to the generic guard with
|
||||
* the provider-keys interlude — no sk-or family check anymore.
|
||||
*/
|
||||
export function validateKeyShape(id: string, rawValue: string): KeyShapeResult {
|
||||
const value = rawValue.trim();
|
||||
@@ -82,10 +152,6 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult {
|
||||
? { ok: true as const }
|
||||
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
||||
switch (id) {
|
||||
case "OPENROUTER_API_KEY":
|
||||
return value.startsWith("sk-or-")
|
||||
? nonEmpty(12)
|
||||
: { ok: false, error: `${id} must start with "sk-or-" (OpenRouter's key format)` };
|
||||
case "GOOGLE_SEARCH_API_KEY":
|
||||
return value.startsWith("AIza")
|
||||
? nonEmpty(10)
|
||||
@@ -104,6 +170,14 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult {
|
||||
}
|
||||
}
|
||||
|
||||
/** The generic truncation guard for MODEL-PROVIDER keys (families vary —
|
||||
* only paste hygiene applies; owner spec: trim, no whitespace, min 12). */
|
||||
export function genericProviderKeyGuard(id: string, rawValue: string): KeyShapeResult {
|
||||
const value = rawValue.trim();
|
||||
if (value.length >= 12 && !/\s/.test(value)) return { ok: true };
|
||||
return { ok: false, error: `${id} must be at least 12 characters with no whitespace` };
|
||||
}
|
||||
|
||||
/** A masked key never leaves the server as plaintext (owner decision:
|
||||
* last 4 chars + length — enough to identify which key is stored after
|
||||
* rotating between several, and to spot a mis-pasted family). */
|
||||
|
||||
Reference in New Issue
Block a user