diff --git a/apps/harness/next.config.js b/apps/harness/next.config.js index c10e07d..2c7ac9a 100644 --- a/apps/harness/next.config.js +++ b/apps/harness/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + instrumentationHook: true, }; module.exports = nextConfig; diff --git a/apps/harness/src/instrumentation.ts b/apps/harness/src/instrumentation.ts new file mode 100644 index 0000000..7b11d1d --- /dev/null +++ b/apps/harness/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { boot } = await import("./lib/boot"); + await boot(); + } +} diff --git a/apps/harness/src/lib/boot.ts b/apps/harness/src/lib/boot.ts new file mode 100644 index 0000000..3736a7e --- /dev/null +++ b/apps/harness/src/lib/boot.ts @@ -0,0 +1,196 @@ +// Auto-discovery: load credentials from mounted secrets + env vars, +// discover models from providers, and create default agent configs. + +import { readFileSync, existsSync } from "node:fs"; +import { upsertCredential, getRawCredentialsByProvider, type Provider } from "./credentials"; +import { upsertCuratedModel, getCuratedModels } from "./model-store"; +import { upsertAgentConfig, getAllAgentConfigs, type AgentRuntime } from "./agents"; +import { fetchAllModels } from "./model-providers"; + +let booted = false; + +// Well-known models with pricing (used as fallback when API discovery returns +// models without pricing info, and to enable cost tracking from the start). +const KNOWN_MODELS: Record = { + "claude-opus-4-20250514": { name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.015, costPer1kOutput: 0.075 }, + "claude-sonnet-4-20250514": { name: "Claude Sonnet 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 }, + "claude-haiku-4-20250514": { name: "Claude Haiku 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 }, + "gpt-4o": { name: "GPT-4o", provider: "openai", contextWindow: 128000, costPer1kInput: 0.0025, costPer1kOutput: 0.01 }, + "gpt-4o-mini": { name: "GPT-4o Mini", provider: "openai", contextWindow: 128000, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 }, + "o3": { name: "o3", provider: "openai", contextWindow: 200000, costPer1kInput: 0.01, costPer1kOutput: 0.04 }, + "o4-mini": { name: "o4 Mini", provider: "openai", contextWindow: 200000, costPer1kInput: 0.0011, costPer1kOutput: 0.0044 }, + "gemini-2.5-pro": { name: "Gemini 2.5 Pro", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00125, costPer1kOutput: 0.01 }, + "gemini-2.5-flash": { name: "Gemini 2.5 Flash", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 }, +}; + +// Default agents to create per provider when credentials are available. +// Maps provider → [{ runtime, models[] }]. +const DEFAULT_AGENTS: Record = { + anthropic: [ + { runtime: "claude-code", models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"] }, + { runtime: "opencode", models: ["claude-sonnet-4-20250514"] }, + ], + openai: [ + { runtime: "codex", models: ["o3", "o4-mini"] }, + ], + google: [ + { runtime: "opencode", models: ["gemini-2.5-pro", "gemini-2.5-flash"] }, + ], + openrouter: [ + { runtime: "opencode", models: ["claude-sonnet-4-20250514"] }, + ], +}; + +const RUNTIME_LABELS: Record = { + "claude-code": "Claude Code", + "codex": "Codex", + "opencode": "OpenCode", +}; + +// ─── CREDENTIAL LOADING ───────────────────────────────────── + +function loadCredentialsFromEnv() { + const envMap: [string, Provider, string][] = [ + ["ANTHROPIC_API_KEY", "anthropic", "Anthropic (env)"], + ["OPENAI_API_KEY", "openai", "OpenAI (env)"], + ["GOOGLE_API_KEY", "google", "Google (env)"], + ["OPENROUTER_API_KEY", "openrouter", "OpenRouter (env)"], + ["OPENCODE_ZEN_API_KEY", "opencode-zen", "OpenCode Zen (env)"], + ["GITHUB_TOKEN", "github", "GitHub (env)"], + ["GH_TOKEN", "github", "GitHub (env)"], + ["GITLAB_TOKEN", "gitlab", "GitLab (env)"], + ]; + + for (const [envVar, provider, label] of envMap) { + const token = process.env[envVar]; + if (!token) continue; + // Don't overwrite if already loaded from file + if (getRawCredentialsByProvider(provider).length > 0) continue; + upsertCredential({ id: `env-${provider}`, provider, label, token }); + } +} + +function loadClaudeCredentials() { + const configDir = process.env.CLAUDE_CONFIG_DIR; + if (!configDir) return; + + const credPath = `${configDir}/.credentials.json`; + if (!existsSync(credPath)) return; + + try { + const raw = JSON.parse(readFileSync(credPath, "utf-8")); + + // Claude Code OAuth credentials → extract access token for Anthropic API + if (raw.claudeAiOauth?.accessToken) { + upsertCredential({ + id: "file-anthropic", + provider: "anthropic", + label: `Claude ${raw.claudeAiOauth.subscriptionType || "API"} (mounted)`, + token: raw.claudeAiOauth.accessToken, + }); + } + } catch { + // ignore parse errors + } +} + +function loadOpenCodeCredentials() { + const configDir = process.env.OPENCODE_CONFIG_DIR; + if (!configDir) return; + + const authPath = `${configDir}/auth.json`; + if (!existsSync(authPath)) return; + + try { + const raw = JSON.parse(readFileSync(authPath, "utf-8")); + + // OpenCode auth.json: { "provider": { "type": "api", "key": "..." } } + const providerMap: Record = { + anthropic: "anthropic", + openai: "openai", + google: "google", + openrouter: "openrouter", + opencode: "opencode-zen", + }; + + for (const [key, entry] of Object.entries(raw)) { + const provider = providerMap[key]; + if (!provider || typeof entry !== "object" || !entry) continue; + const token = (entry as Record).key; + if (typeof token !== "string" || !token) continue; + // Don't overwrite credentials already loaded from Claude config + if (getRawCredentialsByProvider(provider).length > 0) continue; + upsertCredential({ + id: `file-${provider}`, + provider, + label: `${key} (mounted)`, + token, + }); + } + } catch { + // ignore parse errors + } +} + +// ─── MODEL + AGENT AUTO-DISCOVERY ─────────────────────────── + +async function discoverModelsAndAgents() { + // Fetch live models from all providers with credentials + const liveModels = await fetchAllModels(); + + // Upsert discovered models into curated store, enriched with known pricing + for (const m of liveModels) { + const known = KNOWN_MODELS[m.id]; + upsertCuratedModel({ + id: m.id, + name: known?.name || m.name, + provider: m.provider, + enabled: true, + contextWindow: m.contextWindow || known?.contextWindow, + costPer1kInput: known?.costPer1kInput, + costPer1kOutput: known?.costPer1kOutput, + }); + } + + // Also add well-known models that have credentials but weren't returned by API + // (e.g. newer models not yet in /v1/models listing) + for (const [id, info] of Object.entries(KNOWN_MODELS)) { + if (getCuratedModels().some(m => m.id === id)) continue; + if (getRawCredentialsByProvider(info.provider as Provider).length === 0) continue; + upsertCuratedModel({ id, ...info, enabled: true }); + } + + // Create default agent configs if none exist yet + if (getAllAgentConfigs().length > 0) return; + + for (const [provider, runtimes] of Object.entries(DEFAULT_AGENTS)) { + if (getRawCredentialsByProvider(provider as Provider).length === 0) continue; + for (const { runtime, models } of runtimes) { + for (const modelId of models) { + const known = KNOWN_MODELS[modelId]; + const name = `${RUNTIME_LABELS[runtime]} · ${known?.name || modelId}`; + const id = `auto-${runtime}-${modelId}`.replace(/[^a-z0-9-]/g, "-"); + upsertAgentConfig({ id, name, runtime, modelId, provider }); + } + } + } +} + +// ─── BOOT ─────────────────────────────────────────────────── + +export async function boot() { + if (booted) return; + booted = true; + + // 1. Load credentials from mounted secrets (files take priority) + loadClaudeCredentials(); + loadOpenCodeCredentials(); + // 2. Fill gaps from env vars + loadCredentialsFromEnv(); + // 3. Discover models and create agents (async, best-effort) + try { + await discoverModelsAndAgents(); + } catch { + // non-fatal — models/agents will be empty until manually configured + } +}