Mechanical fallout only; semantic deltas flagged in the wave report:
- shouldStopAfterTurn -> finishTurn ({action:'end'} wrap; loop order verified
equivalent for Wave C stranding)
- transcript rework: seeded leading SystemMessage filtered at both
state.messages read seams (ui-stream settleWith, pi-run slice); prompt
and tool declarations now ride the transcript
- streamOnce sites normalizeContext() (Context -> TranscriptContext)
- AgentToolResult details / ToolCall.arguments JsonValue narrowing (casts)
- AUTH_PROVIDER_KEYS regenerated (meta added); glm-5.3-flash price pin
follows the 0.87.1 catalog (0.15/0.5/0.05)
- vanish probe: 0/12 vs 2/6 baseline; live smoke green on PORT=3004
204 lines
9.8 KiB
TypeScript
204 lines
9.8 KiB
TypeScript
/**
|
|
* The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19).
|
|
* PURE, browser-safe — no fs, no env. The catalog drives the auth page's
|
|
* key list (label, description, where-to-get docs link) and the write-side
|
|
* shape validation (owner decision: SHAPE-ONLY validation, no live pings —
|
|
* a revoked key looks fine until a real call fails; that trade was chosen
|
|
* over phoning providers).
|
|
*
|
|
* The store itself is the long-standing flat {VAR: value} auth.json map
|
|
* (see config.AUTH_FILE): keys are identified BY their environment-variable
|
|
* name — the same name consumers read today (OPENROUTER_API_KEY, …), so the
|
|
* catalog id IS the file key and the env var, one name everywhere.
|
|
*
|
|
* Unknown vars found in the file (outside the catalog) are PRESERVED
|
|
* verbatim and surfaced as generic entries — forward-compat for future
|
|
* entries (e.g. a plans-login token) without a schema change — but they
|
|
* are not hand-writable through the curated API (only the catalog's keys
|
|
* have shapes worth checking and docs worth showing).
|
|
*/
|
|
|
|
/** One curated key entry. `id` is the env-var/file key name. */
|
|
export interface AuthKeyInfo {
|
|
id: string;
|
|
/** Human label for the settings page. */
|
|
label: string;
|
|
/** One-sentence what-is-this-for. */
|
|
description: string;
|
|
/** Where the user gets a key (opens in the real browser via the shell). */
|
|
docsUrl: string;
|
|
}
|
|
|
|
export const AUTH_KEYS: readonly AuthKeyInfo[] = [
|
|
{
|
|
id: "DEEPGRAM_API_KEY",
|
|
label: "Deepgram",
|
|
description: "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.",
|
|
docsUrl: "https://console.deepgram.com/",
|
|
},
|
|
{
|
|
id: "TAVILY_API_KEY",
|
|
label: "Tavily",
|
|
description: "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.",
|
|
docsUrl: "https://app.tavily.com/",
|
|
},
|
|
{
|
|
id: "BRAVE_API_KEY",
|
|
label: "Brave Search",
|
|
description: "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.",
|
|
docsUrl: "https://brave.com/search/api/",
|
|
},
|
|
] as const;
|
|
|
|
const AUTH_KEY_IDS: ReadonlySet<string> = new Set(AUTH_KEYS.map((k) => k.id));
|
|
|
|
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: "meta", envVar: "META_API_KEY", label: "Meta" },
|
|
{ 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 };
|
|
|
|
/**
|
|
* The write-side shape gate (owner decision: reject bad pastes with 400 —
|
|
* a truncated or wrong-family key can never land in the store). Values are
|
|
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
|
*
|
|
* CURATED keys carry family rules (Tavily's tvly-… prefix; Brave and
|
|
* Deepgram publish no format — length floors + paste hygiene only).
|
|
* 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();
|
|
const nonEmpty = (min: number) =>
|
|
value.length >= min && !/\s/.test(value)
|
|
? { ok: true as const }
|
|
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
|
switch (id) {
|
|
case "TAVILY_API_KEY":
|
|
return value.startsWith("tvly-")
|
|
? nonEmpty(20)
|
|
: { ok: false, error: `${id} must start with "tvly-" (Tavily's key format)` };
|
|
case "BRAVE_API_KEY":
|
|
// No public format — a length floor + paste hygiene only.
|
|
return nonEmpty(20);
|
|
case "DEEPGRAM_API_KEY":
|
|
// No public format — the length floor only catches truncated pastes.
|
|
return nonEmpty(20);
|
|
default:
|
|
return { ok: false, error: `unknown key "${id}" (supported: ${[...AUTH_KEY_IDS].join(", ")})` };
|
|
}
|
|
}
|
|
|
|
/** 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). */
|
|
export function maskKey(value: string): string {
|
|
const v = value.trim();
|
|
if (v.length <= 4) return `…${v}`; // degenerate paste — still never the whole value
|
|
return `…${v.slice(-4)}`;
|
|
}
|
|
|
|
/** One key's status row for the GET view (never carries plaintext). */
|
|
export interface AuthKeyStatus {
|
|
id: string;
|
|
/** Catalog metadata when the key is curated; null for unknown-file-var
|
|
* entries (preserved + surfaced, but no curated form to edit through). */
|
|
info: AuthKeyInfo | null;
|
|
/** A key is present when EITHER the environment or the file has it. */
|
|
present: boolean;
|
|
/** The masked tail + character count, e.g. "…ab12 (64 chars)". */
|
|
masked: string | null;
|
|
/** Where the live value comes from, given env-wins precedence. */
|
|
source: "env" | "file" | "none";
|
|
/** True when the file holds a value that the environment shadows — the
|
|
* settings page explains why a saved key is not the live one. */
|
|
shadowedByEnv: boolean;
|
|
}
|