feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
250
packages/llm/script/recording-cost-report.ts
Normal file
250
packages/llm/script/recording-cost-report.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
|
||||
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json"
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
type Pricing = {
|
||||
readonly input?: number
|
||||
readonly output?: number
|
||||
readonly cache_read?: number
|
||||
readonly cache_write?: number
|
||||
readonly reasoning?: number
|
||||
}
|
||||
|
||||
type Usage = {
|
||||
readonly inputTokens: number
|
||||
readonly outputTokens: number
|
||||
readonly cacheReadTokens: number
|
||||
readonly cacheWriteTokens: number
|
||||
readonly reasoningTokens: number
|
||||
readonly reportedCost: number
|
||||
}
|
||||
|
||||
type Row = Usage & {
|
||||
readonly cassette: string
|
||||
readonly provider: string
|
||||
readonly model: string
|
||||
readonly estimatedCost: number
|
||||
readonly pricingSource: string
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0)
|
||||
|
||||
const asString = (value: unknown) => (typeof value === "string" ? value : undefined)
|
||||
|
||||
const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown
|
||||
|
||||
const walk = async (dir: string): Promise<ReadonlyArray<string>> =>
|
||||
(await fs.readdir(dir, { withFileTypes: true }))
|
||||
.flatMap((entry) => {
|
||||
const file = path.join(dir, entry.name)
|
||||
return entry.isDirectory() ? [] : [file]
|
||||
})
|
||||
.concat(
|
||||
...(await Promise.all(
|
||||
(await fs.readdir(dir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => walk(path.join(dir, entry.name))),
|
||||
)),
|
||||
)
|
||||
|
||||
const providerFromUrl = (url: string) => {
|
||||
if (url.includes("api.openai.com")) return "openai"
|
||||
if (url.includes("api.anthropic.com")) return "anthropic"
|
||||
if (url.includes("generativelanguage.googleapis.com")) return "google"
|
||||
if (url.includes("bedrock")) return "amazon-bedrock"
|
||||
if (url.includes("openrouter.ai")) return "openrouter"
|
||||
if (url.includes("api.x.ai")) return "xai"
|
||||
if (url.includes("api.groq.com")) return "groq"
|
||||
if (url.includes("api.deepseek.com")) return "deepseek"
|
||||
if (url.includes("api.together.xyz")) return "togetherai"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const providerAliases: Record<string, ReadonlyArray<string>> = {
|
||||
openai: ["openai"],
|
||||
anthropic: ["anthropic"],
|
||||
google: ["google"],
|
||||
"amazon-bedrock": ["amazon-bedrock"],
|
||||
openrouter: ["openrouter", "openai", "anthropic", "google"],
|
||||
xai: ["xai"],
|
||||
groq: ["groq"],
|
||||
deepseek: ["deepseek"],
|
||||
togetherai: ["togetherai"],
|
||||
}
|
||||
|
||||
const modelAliases = (model: string) => [
|
||||
model,
|
||||
model.replace(/^models\//, ""),
|
||||
model.replace(/-\d{8}$/, ""),
|
||||
model.replace(/-\d{4}-\d{2}-\d{2}$/, ""),
|
||||
model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""),
|
||||
model.replace(/^openai\//, ""),
|
||||
model.replace(/^anthropic\//, ""),
|
||||
model.replace(/^google\//, ""),
|
||||
]
|
||||
|
||||
const pricingFor = (models: JsonRecord, provider: string, model: string) => {
|
||||
for (const providerID of providerAliases[provider] ?? [provider]) {
|
||||
const providerEntry = models[providerID]
|
||||
if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue
|
||||
for (const modelID of modelAliases(model)) {
|
||||
const modelEntry = providerEntry.models[modelID]
|
||||
if (isRecord(modelEntry) && isRecord(modelEntry.cost))
|
||||
return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` }
|
||||
}
|
||||
}
|
||||
return { pricing: undefined, source: "missing" }
|
||||
}
|
||||
|
||||
const estimateCost = (usage: Usage, pricing: Pricing | undefined) => {
|
||||
if (!pricing) return 0
|
||||
return (
|
||||
(usage.inputTokens * (pricing.input ?? 0) +
|
||||
usage.outputTokens * (pricing.output ?? 0) +
|
||||
usage.cacheReadTokens * (pricing.cache_read ?? 0) +
|
||||
usage.cacheWriteTokens * (pricing.cache_write ?? 0) +
|
||||
usage.reasoningTokens * (pricing.reasoning ?? 0)) /
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
|
||||
const emptyUsage = (): Usage => ({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
reportedCost: 0,
|
||||
})
|
||||
|
||||
const addUsage = (a: Usage, b: Usage): Usage => ({
|
||||
inputTokens: a.inputTokens + b.inputTokens,
|
||||
outputTokens: a.outputTokens + b.outputTokens,
|
||||
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
||||
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
||||
reasoningTokens: a.reasoningTokens + b.reasoningTokens,
|
||||
reportedCost: a.reportedCost + b.reportedCost,
|
||||
})
|
||||
|
||||
const usageFromObject = (usage: unknown): Usage => {
|
||||
if (!isRecord(usage)) return emptyUsage()
|
||||
const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {}
|
||||
const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {}
|
||||
const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {}
|
||||
const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {}
|
||||
const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens)
|
||||
return {
|
||||
inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens),
|
||||
outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens),
|
||||
cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens),
|
||||
cacheWriteTokens,
|
||||
reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens),
|
||||
reportedCost: asNumber(usage.cost),
|
||||
}
|
||||
}
|
||||
|
||||
const jsonPayloads = (body: string) =>
|
||||
body
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trim())
|
||||
.filter((line) => line !== "" && line !== "[DONE]")
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as unknown]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const usageFromResponseBody = (body: string) =>
|
||||
jsonPayloads(body).reduce<Usage>((usage, payload) => {
|
||||
if (!isRecord(payload)) return usage
|
||||
return addUsage(
|
||||
usage,
|
||||
addUsage(
|
||||
usageFromObject(payload.usage),
|
||||
usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined),
|
||||
),
|
||||
)
|
||||
}, emptyUsage())
|
||||
|
||||
const modelFromRequest = (request: unknown) => {
|
||||
if (!isRecord(request)) return "unknown"
|
||||
const requestBody = asString(request.body)
|
||||
if (!requestBody) return "unknown"
|
||||
try {
|
||||
const body = JSON.parse(requestBody) as unknown
|
||||
if (!isRecord(body)) return "unknown"
|
||||
return asString(body.model) ?? "unknown"
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => {
|
||||
if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined
|
||||
const first = cassette.interactions.find(isRecord)
|
||||
if (!first || !isRecord(first.request)) return undefined
|
||||
const provider = providerFromUrl(asString(first.request.url) ?? "")
|
||||
const model = modelFromRequest(first.request)
|
||||
const usage = cassette.interactions.filter(isRecord).reduce<Usage>((total, interaction) => {
|
||||
if (!isRecord(interaction.response)) return total
|
||||
const responseBody = asString(interaction.response.body)
|
||||
if (!responseBody) return total
|
||||
return addUsage(total, usageFromResponseBody(responseBody))
|
||||
}, emptyUsage())
|
||||
const priced = pricingFor(models, provider, model)
|
||||
return {
|
||||
cassette: path.relative(RECORDINGS_DIR, file),
|
||||
provider,
|
||||
model,
|
||||
...usage,
|
||||
estimatedCost: estimateCost(usage, priced.pricing),
|
||||
pricingSource: priced.source,
|
||||
}
|
||||
}
|
||||
|
||||
const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`)
|
||||
const tokens = (value: number) => value.toLocaleString("en-US")
|
||||
|
||||
const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord
|
||||
const rows = (
|
||||
await Promise.all(
|
||||
(await walk(RECORDINGS_DIR))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map(async (file) => rowFor(models, file, await readJson(file))),
|
||||
)
|
||||
).filter((row): row is Row => row !== undefined)
|
||||
|
||||
const totals = rows.reduce(
|
||||
(total, row) => ({
|
||||
...addUsage(total, row),
|
||||
estimatedCost: total.estimatedCost + row.estimatedCost,
|
||||
}),
|
||||
{ ...emptyUsage(), estimatedCost: 0 },
|
||||
)
|
||||
|
||||
console.log("# Recording Cost Report")
|
||||
console.log("")
|
||||
console.log(`Pricing: ${MODELS_DEV_URL}`)
|
||||
console.log(`Cassettes: ${rows.length}`)
|
||||
console.log(`Reported cost: ${money(totals.reportedCost)}`)
|
||||
console.log(`Estimated cost: ${money(totals.estimatedCost)}`)
|
||||
console.log("")
|
||||
console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |")
|
||||
console.log("|---|---:|---:|---:|---:|---:|---:|---|---|")
|
||||
for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) {
|
||||
if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue
|
||||
console.log(
|
||||
`| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`,
|
||||
)
|
||||
}
|
||||
542
packages/llm/script/setup-recording-env.ts
Normal file
542
packages/llm/script/setup-recording-env.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as path from "node:path"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AwsV4Signer } from "aws4fetch"
|
||||
import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import * as ProviderShared from "../src/protocols/shared"
|
||||
import * as Cloudflare from "../src/providers/cloudflare"
|
||||
|
||||
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
|
||||
readonly secret?: boolean
|
||||
}>
|
||||
readonly validate?: (env: Env) => Effect.Effect<string | undefined, unknown, HttpClient.HttpClient>
|
||||
}
|
||||
|
||||
type Env = Record<string, string>
|
||||
|
||||
const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
tier: "core",
|
||||
note: "Native OpenAI Chat / Responses recorded tests",
|
||||
vars: [{ name: "OPENAI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
tier: "core",
|
||||
note: "Native Anthropic Messages recorded tests",
|
||||
vars: [{ name: "ANTHROPIC_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)),
|
||||
}),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
label: "Google Gemini",
|
||||
tier: "core",
|
||||
note: "Native Gemini recorded tests",
|
||||
vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`,
|
||||
).pipe(executeRequest),
|
||||
},
|
||||
{
|
||||
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 },
|
||||
],
|
||||
validate: (env) => validateBedrock(env),
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
label: "Groq",
|
||||
tier: "canary",
|
||||
note: "Fast OpenAI-compatible canary for text/tool streaming",
|
||||
vars: [{ name: "GROQ_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
label: "OpenRouter",
|
||||
tier: "canary",
|
||||
note: "Router canary for OpenAI-compatible text/tool streaming",
|
||||
vars: [{ name: "OPENROUTER_API_KEY" }],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: "https://openrouter.ai/api/v1/chat/completions",
|
||||
token: Redacted.make(env.OPENROUTER_API_KEY),
|
||||
model: "openai/gpt-4o-mini",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
label: "xAI",
|
||||
tier: "canary",
|
||||
note: "OpenAI-compatible xAI chat endpoint",
|
||||
vars: [{ name: "XAI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
tier: "canary",
|
||||
note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...",
|
||||
vars: [
|
||||
{ name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false },
|
||||
{
|
||||
name: "CLOUDFLARE_GATEWAY_ID",
|
||||
label: "Cloudflare AI Gateway ID (defaults to default)",
|
||||
optional: true,
|
||||
secret: false,
|
||||
},
|
||||
{ name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" },
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.aiGatewayBaseURL({
|
||||
accountId: env.CLOUDFLARE_ACCOUNT_ID,
|
||||
gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
|
||||
})}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
|
||||
tokenHeader: "cf-aig-authorization",
|
||||
model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "cloudflare-workers-ai",
|
||||
label: "Cloudflare Workers AI",
|
||||
tier: "canary",
|
||||
note: "Direct Workers AI OpenAI-compatible endpoint; supports model ids like @cf/meta/...",
|
||||
vars: [
|
||||
{ name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false },
|
||||
{ name: "CLOUDFLARE_API_KEY", label: "Cloudflare Workers AI API token" },
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
|
||||
model: "@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
label: "DeepSeek",
|
||||
tier: "compatible",
|
||||
note: "Existing OpenAI-compatible recorded tests",
|
||||
vars: [{ name: "DEEPSEEK_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "togetherai",
|
||||
label: "TogetherAI",
|
||||
tier: "compatible",
|
||||
note: "Existing OpenAI-compatible text/tool recorded tests",
|
||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.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" }],
|
||||
validate: (env) => validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.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" }],
|
||||
validate: (env) => validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "venice",
|
||||
label: "Venice",
|
||||
tier: "optional",
|
||||
note: "OpenAI-compatible bridge",
|
||||
vars: [{ name: "VENICE_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "cerebras",
|
||||
label: "Cerebras",
|
||||
tier: "optional",
|
||||
note: "OpenAI-compatible bridge",
|
||||
vars: [{ name: "CEREBRAS_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "deepinfra",
|
||||
label: "DeepInfra",
|
||||
tier: "optional",
|
||||
note: "OpenAI-compatible bridge",
|
||||
vars: [{ name: "DEEPINFRA_API_KEY" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "fireworks",
|
||||
label: "Fireworks",
|
||||
tier: "optional",
|
||||
note: "OpenAI-compatible bridge",
|
||||
vars: [{ name: "FIREWORKS_API_KEY" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.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 interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
||||
|
||||
const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name))))
|
||||
|
||||
const providersForOption = (value: string | undefined) => {
|
||||
if (!value || value === "recommended")
|
||||
return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary")
|
||||
if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional")
|
||||
if (value === "all") return PROVIDERS
|
||||
const ids = new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
return PROVIDERS.filter((provider) => ids.has(provider.id))
|
||||
}
|
||||
|
||||
const chooseProviders = async () => {
|
||||
if (providerOption) return providersForOption(providerOption)
|
||||
return providersForOption("recommended")
|
||||
}
|
||||
|
||||
const catchMissingFile = (error: PlatformError.PlatformError) => {
|
||||
if (error.reason._tag === "NotFound") return Effect.succeed("")
|
||||
return Effect.fail(error)
|
||||
}
|
||||
|
||||
const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile))
|
||||
})
|
||||
|
||||
const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
|
||||
Config.string(name)
|
||||
.parse(provider)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onFailure: () => undefined,
|
||||
onSuccess: (value) => value,
|
||||
}),
|
||||
)
|
||||
|
||||
const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) {
|
||||
const provider = ConfigProvider.fromDotEnvContents(contents)
|
||||
return Object.fromEntries(
|
||||
(yield* Effect.forEach(envNames, (name) =>
|
||||
readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)),
|
||||
)).filter((entry): entry is readonly [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
})
|
||||
|
||||
const quote = (value: string) => JSON.stringify(value)
|
||||
|
||||
const status = (name: string, fileEnv: Env) => {
|
||||
if (fileEnv[name]) return "file"
|
||||
if (process.env[name]) return "shell"
|
||||
return "missing"
|
||||
}
|
||||
|
||||
const statusLine = (provider: Provider, fileEnv: Env) =>
|
||||
[
|
||||
`${provider.label} (${provider.tier})`,
|
||||
provider.note,
|
||||
...provider.vars.map((item) => {
|
||||
const value = status(item.name, fileEnv)
|
||||
const suffix = item.optional ? " optional" : ""
|
||||
return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}`
|
||||
}),
|
||||
].join("\n")
|
||||
|
||||
const printStatus = (providers: ReadonlyArray<Provider>, fileEnv: Env) => {
|
||||
prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`)
|
||||
}
|
||||
|
||||
const exitIfCancel = <A>(value: A | symbol): A => {
|
||||
if (!prompts.isCancel(value)) return value as A
|
||||
prompts.cancel("Cancelled")
|
||||
process.exit(130)
|
||||
}
|
||||
|
||||
const upsertEnv = (contents: string, values: Env) => {
|
||||
const names = Object.keys(values)
|
||||
const seen = new Set<string>()
|
||||
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 providerRequiredStatus = (provider: Provider, fileEnv: Env) => {
|
||||
const required = requiredVars(provider)
|
||||
if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing"
|
||||
if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell"
|
||||
return "already added"
|
||||
}
|
||||
|
||||
const requiredVars = (provider: Provider) => provider.vars.filter((item) => !item.optional)
|
||||
|
||||
const promptVars = (provider: Provider) => provider.vars.filter((item) => !item.optional || item.secret === false)
|
||||
|
||||
const processEnv = (): Env =>
|
||||
Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
|
||||
|
||||
const envValue = (env: Env, names: ReadonlyArray<string>) => names.map((name) => env[name]).find(Boolean) ?? ""
|
||||
|
||||
const envWithValues = (fileEnv: Env, values: Env): Env => ({
|
||||
...processEnv(),
|
||||
...fileEnv,
|
||||
...values,
|
||||
})
|
||||
|
||||
const responseError = Effect.fn("RecordingEnv.responseError")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
if (response.status >= 200 && response.status < 300) return undefined
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
|
||||
})
|
||||
|
||||
const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
return yield* http.execute(request).pipe(Effect.flatMap(responseError))
|
||||
})
|
||||
|
||||
const validateBearer = (url: string, token: Redacted.Redacted<string>, headers: Record<string, string> = {}) =>
|
||||
HttpClientRequest.get(url).pipe(
|
||||
HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }),
|
||||
executeRequest,
|
||||
)
|
||||
|
||||
const validateChat = (input: {
|
||||
readonly url: string
|
||||
readonly token: Redacted.Redacted<string>
|
||||
readonly tokenHeader?: string
|
||||
readonly model: string
|
||||
readonly headers?: Record<string, string>
|
||||
}) =>
|
||||
ProviderShared.jsonPost({
|
||||
url: input.url,
|
||||
headers: { ...input.headers, [input.tokenHeader ?? "authorization"]: `Bearer ${Redacted.value(input.token)}` },
|
||||
body: ProviderShared.encodeJson({
|
||||
model: input.model,
|
||||
messages: [{ role: "user", content: "Reply with exactly: ok" }],
|
||||
max_tokens: 3,
|
||||
temperature: 0,
|
||||
}),
|
||||
}).pipe(executeRequest)
|
||||
|
||||
const validateBedrock = (env: Env) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* Effect.promise(() =>
|
||||
new AwsV4Signer({
|
||||
url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`,
|
||||
method: "GET",
|
||||
service: "bedrock",
|
||||
region: env.BEDROCK_RECORDING_REGION || "us-east-1",
|
||||
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
||||
sessionToken: env.AWS_SESSION_TOKEN || undefined,
|
||||
}).sign(),
|
||||
)
|
||||
return yield* HttpClientRequest.get(request.url.toString()).pipe(
|
||||
HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())),
|
||||
executeRequest,
|
||||
)
|
||||
})
|
||||
|
||||
const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) {
|
||||
return yield* (provider.validate?.(env) ?? Effect.succeed("no lightweight validator")).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof Error) return Effect.succeed(error.message)
|
||||
return Effect.succeed(String(error))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* (
|
||||
providers: ReadonlyArray<Provider>,
|
||||
env: Env,
|
||||
) {
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Validating credentials")
|
||||
const results = yield* Effect.forEach(
|
||||
providers,
|
||||
(provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
spinner.stop("Validation complete")
|
||||
prompts.note(
|
||||
results
|
||||
.map(
|
||||
(result) =>
|
||||
`${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Credential validation",
|
||||
)
|
||||
})
|
||||
|
||||
const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true })
|
||||
yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 })
|
||||
})
|
||||
|
||||
const prompt = <A>(run: () => Promise<A | symbol>) => Effect.promise(run).pipe(Effect.map(exitIfCancel))
|
||||
|
||||
const chooseConfigurableProviders = Effect.fn("RecordingEnv.chooseConfigurableProviders")(function* (
|
||||
providers: ReadonlyArray<Provider>,
|
||||
fileEnv: Env,
|
||||
) {
|
||||
const configurable = providers.filter((provider) => requiredVars(provider).length > 0)
|
||||
const selected = yield* prompt<ReadonlyArray<string>>(() =>
|
||||
prompts.multiselect({
|
||||
message: "Select provider credentials to add or override",
|
||||
options: configurable.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
hint: `${providerRequiredStatus(provider, fileEnv)} - ${requiredVars(provider)
|
||||
.map((item) => item.name)
|
||||
.join(", ")}`,
|
||||
})),
|
||||
initialValues: configurable
|
||||
.filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing")
|
||||
.map((provider) => provider.id),
|
||||
}),
|
||||
)
|
||||
return configurable.filter((provider) => selected.includes(provider.id))
|
||||
})
|
||||
|
||||
const promptEnvVar = (item: Provider["vars"][number]) =>
|
||||
prompt<string>(() => {
|
||||
const input = {
|
||||
message: item.label ?? item.name,
|
||||
validate: (input: string | undefined) => {
|
||||
if (item.optional) return undefined
|
||||
return !input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined
|
||||
},
|
||||
}
|
||||
return item.secret === false ? prompts.text(input) : prompts.password(input)
|
||||
})
|
||||
|
||||
const promptProviderValues = Effect.fn("RecordingEnv.promptProviderValues")(function* (
|
||||
providers: ReadonlyArray<Provider>,
|
||||
) {
|
||||
const values: Env = {}
|
||||
for (const provider of providers) {
|
||||
prompts.log.info(`${provider.label}: ${provider.note}`)
|
||||
for (const item of promptVars(provider)) {
|
||||
if (values[item.name]) continue
|
||||
const value = yield* promptEnvVar(item)
|
||||
if (value !== "") values[item.name] = value
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
const main = Effect.fn("RecordingEnv.main")(function* () {
|
||||
prompts.intro("LLM recording credentials")
|
||||
const contents = yield* readEnvFile()
|
||||
const fileEnv = yield* parseEnv(contents)
|
||||
const providers = yield* Effect.promise(() => chooseProviders())
|
||||
printStatus(providers, fileEnv)
|
||||
if (checkOnly) {
|
||||
prompts.outro("Check complete")
|
||||
return
|
||||
}
|
||||
if (!interactive) {
|
||||
prompts.outro("Run this command in a terminal to enter credentials")
|
||||
return
|
||||
}
|
||||
|
||||
const selectedProviders = yield* chooseConfigurableProviders(providers, fileEnv)
|
||||
const values = yield* promptProviderValues(selectedProviders)
|
||||
|
||||
if (Object.keys(values).length === 0) {
|
||||
prompts.outro("No changes")
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
interactive &&
|
||||
(yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true })))
|
||||
) {
|
||||
yield* validateProviders(selectedProviders, envWithValues(fileEnv, values))
|
||||
}
|
||||
|
||||
yield* writeEnvFile(upsertEnv(contents, values))
|
||||
prompts.log.success(
|
||||
`Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`,
|
||||
)
|
||||
prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.")
|
||||
})
|
||||
|
||||
await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer)))
|
||||
Reference in New Issue
Block a user