diff --git a/.gitignore b/.gitignore index 52a5a04596..19198a7a59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .worktrees .sst .env +.env.local .idea .vscode .codex diff --git a/packages/llm/README.md b/packages/llm/README.md index f84d105c85..5e4f9dd2c3 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -228,3 +228,13 @@ bun test ``` Recorded tests use `@opencode-ai/http-recorder`. To update recordings, run the relevant test with `RECORD=true` and inspect the cassette for redaction before committing. + +Use the credential helper to see which local keys are present and add missing ones to `packages/llm/.env.local`: + +```sh +bun run setup:recording-env +bun run setup:recording-env -- --check +bun run setup:recording-env -- --providers groq,openrouter,xai +``` + +`.env.local` is ignored by git. Shared team credentials should live in a password manager or vault; this helper only writes your local test environment. diff --git a/packages/llm/package.json b/packages/llm/package.json index b4795487e4..a4bf2699b5 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -6,6 +6,7 @@ "license": "MIT", "private": true, "scripts": { + "setup:recording-env": "bun run script/setup-recording-env.ts", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" diff --git a/packages/llm/script/setup-recording-env.ts b/packages/llm/script/setup-recording-env.ts new file mode 100644 index 0000000000..0123ec5b9b --- /dev/null +++ b/packages/llm/script/setup-recording-env.ts @@ -0,0 +1,284 @@ +#!/usr/bin/env bun + +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as readline from "node:readline/promises" +import { stdin as input, stdout as output } from "node:process" + +type Provider = { + readonly id: string + readonly label: string + readonly tier: "core" | "canary" | "compatible" | "optional" + readonly note: string + readonly vars: ReadonlyArray<{ + readonly name: string + readonly label?: string + readonly optional?: boolean + }> +} + +const PROVIDERS: ReadonlyArray = [ + { + id: "openai", + label: "OpenAI", + tier: "core", + note: "Native OpenAI Chat / Responses recorded tests", + vars: [{ name: "OPENAI_API_KEY" }], + }, + { + id: "anthropic", + label: "Anthropic", + tier: "core", + note: "Native Anthropic Messages recorded tests", + vars: [{ name: "ANTHROPIC_API_KEY" }], + }, + { + id: "google", + label: "Google Gemini", + tier: "core", + note: "Native Gemini recorded tests", + vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }], + }, + { + id: "bedrock", + label: "Amazon Bedrock", + tier: "core", + note: "Native Bedrock Converse recorded tests", + vars: [ + { name: "AWS_ACCESS_KEY_ID" }, + { name: "AWS_SECRET_ACCESS_KEY" }, + { name: "AWS_SESSION_TOKEN", optional: true }, + { name: "BEDROCK_RECORDING_REGION", optional: true }, + { name: "BEDROCK_MODEL_ID", optional: true }, + ], + }, + { + id: "groq", + label: "Groq", + tier: "canary", + note: "Fast OpenAI-compatible canary for text/tool streaming", + vars: [{ name: "GROQ_API_KEY" }], + }, + { + id: "openrouter", + label: "OpenRouter", + tier: "canary", + note: "Router canary for OpenAI-compatible text/tool streaming", + vars: [{ name: "OPENROUTER_API_KEY" }], + }, + { + id: "xai", + label: "xAI", + tier: "canary", + note: "OpenAI-compatible xAI chat endpoint", + vars: [{ name: "XAI_API_KEY" }], + }, + { + id: "deepseek", + label: "DeepSeek", + tier: "compatible", + note: "Existing OpenAI-compatible recorded tests", + vars: [{ name: "DEEPSEEK_API_KEY" }], + }, + { + id: "togetherai", + label: "TogetherAI", + tier: "compatible", + note: "Existing OpenAI-compatible text/tool recorded tests", + vars: [{ name: "TOGETHER_AI_API_KEY" }], + }, + { + id: "mistral", + label: "Mistral", + tier: "optional", + note: "OpenAI-compatible bridge; native reasoning parity is follow-up work", + vars: [{ name: "MISTRAL_API_KEY" }], + }, + { + id: "perplexity", + label: "Perplexity", + tier: "optional", + note: "OpenAI-compatible bridge; citations/search metadata are follow-up work", + vars: [{ name: "PERPLEXITY_API_KEY" }], + }, + { + id: "venice", + label: "Venice", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "VENICE_API_KEY" }], + }, + { + id: "cerebras", + label: "Cerebras", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "CEREBRAS_API_KEY" }], + }, + { + id: "deepinfra", + label: "DeepInfra", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "DEEPINFRA_API_KEY" }], + }, + { + id: "fireworks", + label: "Fireworks", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "FIREWORKS_API_KEY" }], + }, + { + id: "baseten", + label: "Baseten", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "BASETEN_API_KEY" }], + }, +] + +const args = process.argv.slice(2) +const hasFlag = (name: string) => args.includes(name) +const option = (name: string) => { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local") +const checkOnly = hasFlag("--check") +const providerOption = option("--providers") + +const selectedProviders = () => { + if (!providerOption) return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary") + if (providerOption === "all") return PROVIDERS + const ids = new Set(providerOption.split(",").map((item) => item.trim()).filter(Boolean)) + return PROVIDERS.filter((provider) => ids.has(provider.id)) +} + +const readEnvFile = async () => { + try { + return await Bun.file(envPath).text() + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return "" + throw error + } +} + +const parseEnv = (contents: string) => + Object.fromEntries( + contents + .split(/\r?\n/) + .map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map((match) => [match[1], unquote(match[2] ?? "")]), + ) + +const unquote = (value: string) => { + const trimmed = value.trim() + if (trimmed.startsWith('"') && trimmed.endsWith('"')) return JSON.parse(trimmed) + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1) + return trimmed.split(/\s+#/)[0]?.trim() ?? "" +} + +const quote = (value: string) => JSON.stringify(value) + +const status = (name: string, fileEnv: Record) => { + if (fileEnv[name]) return "file" + if (process.env[name]) return "shell" + return "missing" +} + +const printStatus = (providers: ReadonlyArray, fileEnv: Record) => { + console.log(`Recording env: ${envPath}`) + console.log("") + for (const provider of providers) { + console.log(`${provider.label} (${provider.tier}) - ${provider.note}`) + for (const item of provider.vars) { + const value = status(item.name, fileEnv) + const suffix = item.optional ? " optional" : "" + console.log(` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}`) + } + } + console.log("") +} + +const question = async (rl: readline.Interface, prompt: string) => (await rl.question(prompt)).trim() + +const secret = async (prompt: string) => { + if (!input.isTTY) return "" + output.write(prompt) + input.setRawMode(true) + input.resume() + return await new Promise((resolve) => { + let value = "" + const onData = (buffer: Buffer) => { + const char = buffer.toString("utf8") + if (char === "\u0003") process.exit(130) + if (char === "\r" || char === "\n") { + input.setRawMode(false) + input.off("data", onData) + output.write("\n") + resolve(value) + return + } + if (char === "\u007f") { + value = value.slice(0, -1) + return + } + value += char + } + input.on("data", onData) + }) +} + +const upsertEnv = (contents: string, values: Record) => { + const names = Object.keys(values) + const seen = new Set() + const lines = contents.split(/\r?\n/).map((line) => { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (!match || !names.includes(match[1])) return line + seen.add(match[1]) + return `${match[1]}=${quote(values[match[1]])}` + }) + const missing = names.filter((name) => !seen.has(name)) + if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n") + const prefix = lines.join("\n").trimEnd() + const block = ["", "# Added by bun run setup:recording-env", ...missing.map((name) => `${name}=${quote(values[name])}`)].join("\n") + return `${prefix}${block}\n` +} + +const main = async () => { + const contents = await readEnvFile() + const fileEnv = parseEnv(contents) + const providers = selectedProviders() + printStatus(providers, fileEnv) + if (checkOnly) return + + const rl = readline.createInterface({ input, output }) + const values: Record = {} + for (const provider of providers) { + const missing = provider.vars.filter((item) => !item.optional && status(item.name, fileEnv) === "missing") + if (missing.length === 0) continue + const add = (await question(rl, `Add missing ${provider.label} credential${missing.length === 1 ? "" : "s"}? [y/N] `)).toLowerCase() + if (add !== "y" && add !== "yes") continue + for (const item of missing) { + const value = await secret(`${item.name}${item.label ? ` (${item.label})` : ""}: `) + if (value !== "") values[item.name] = value + } + } + rl.close() + + if (Object.keys(values).length === 0) { + console.log("No changes.") + return + } + + await fs.mkdir(path.dirname(envPath), { recursive: true }) + await fs.writeFile(envPath, upsertEnv(contents, values), { mode: 0o600 }) + console.log(`Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`) + console.log("Keep this file local. For shared team credentials, store the source secrets in your password manager/vault.") +} + +await main()