From 8fe78c05be2cba2d4366ab74232b70f2aa35840a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:40:22 +0000 Subject: [PATCH 1/3] feat: zero-config provider auto-detection + hosted LiteLLM gateway support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-detect the best API provider from environment variables with no manual configuration required. Priority chain: LITELLM_BASE_URL > ANTHROPIC_BASE_URL (proxy) > OPENROUTER_API_KEY > ANTHROPIC_API_KEY. Explicit .forge/providers.json always overrides auto-detection. - Add autoDetectProvider() and listDetectedProviders() to providers.js - Modify activeProvider() to fall back to env-based auto-detection - Support hosted LiteLLM gateways (LITELLM_BASE_URL + LITELLM_API_KEY) where users have no admin access — uses standard claude-* model names - Detect ANTHROPIC_BASE_URL proxy setups automatically - Enhanced providerStatus() with auto-detection source and env var scan - emitGatewayConfig() skips file emit for hosted gateways (user can't modify) - forge init reports detected provider - forge config show/setup display auto-detection state and env scan - Add forge_provider_status MCP tool for AI agent discovery - Add checkProvider to forge doctor health checks - 30 provider tests covering all cases (471 total pass, 0 fail) Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/cli.js | 77 ++++++++++++- src/cortex_mcp.js | 25 +++++ src/doctor.js | 13 +++ src/init.js | 4 +- src/providers.js | 185 ++++++++++++++++++++++++++++++- src/route.js | 30 +++-- test/providers.test.js | 241 ++++++++++++++++++++++++++++++++++++++++- 7 files changed, 553 insertions(+), 22 deletions(-) diff --git a/src/cli.js b/src/cli.js index ba90612..4936f01 100755 --- a/src/cli.js +++ b/src/cli.js @@ -69,7 +69,7 @@ async function run(argv) { if (cmd === "init") { const { init } = await import("./init.js"); const noSettings = argv.includes("--no-settings"); - const { report, bytes, settings } = init({ targetRoot: process.cwd(), noSettings }); + const { report, bytes, settings, detected } = init({ targetRoot: process.cwd(), noSettings }); const wrote = report.filter((r) => r.action === "written").map((r) => r.target); console.log(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); @@ -85,6 +85,11 @@ async function run(argv) { } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); } + if (detected) { + console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); + } else { + console.log(` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`); + } console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``); console.log(` verify: \`${BRAND.cli} doctor\``); return; @@ -864,6 +869,7 @@ async function run(argv) { if (json) return console.log(JSON.stringify({ active: config.active, provider: prov }, null, 2)); console.log(`${BRAND.brand} config\n`); console.log(` provider: ${prov.name} (${prov.label || prov.name})`); + if (prov._autoDetected) console.log(` detected: auto (from ${prov._source})`); console.log(` base URL: ${prov.baseUrl}`); console.log(` env key: ${prov.envKey || "(none)"}${prov.envKey ? (process.env[prov.envKey] ? " ✓ set" : " ✗ not set") : ""}`); console.log(` models:`); @@ -918,15 +924,78 @@ async function run(argv) { console.log(` written to ${r.path}`); return; } - console.error(`config: unknown subcommand "${sub}" (show | providers | provider | model )`); + if (sub === "gateway") { + const { emitGatewayConfig } = await import("./route.js"); + const result = emitGatewayConfig(process.cwd()); + if (typeof result === "object" && !result.ok) { + console.log(` ${result.reason}`); + return; + } + console.log(` wrote ${result}`); + console.log(`\n setup LiteLLM gateway:`); + console.log(` 1. pip install "litellm[proxy]" # pin an exact verified version`); + console.log(` 2. litellm --config litellm.config.yaml`); + console.log(` 3. export ANTHROPIC_BASE_URL=http://localhost:4000`); + console.log(`\n then switch to the gateway provider:`); + console.log(` ${BRAND.cli} config provider litellm`); + console.log(`\n routing flows through: forge route → tier alias → LiteLLM → model`); + return; + } + if (sub === "setup") { + const { providerStatus, listDetectedProviders } = await import("./providers.js"); + const prov = activeProvider(process.cwd()); + const status = providerStatus(process.cwd()); + const detected = listDetectedProviders(); + console.log(`${BRAND.brand} config setup\n`); + console.log(` active provider: ${prov.name} (${prov.label || prov.name})`); + if (prov._autoDetected) console.log(` source: auto-detected from ${prov._source}`); + console.log(); + for (const c of status.checks) { + console.log(` ${c.ok ? "✓" : "✗"} ${c.detail}`); + } + console.log(`\n environment:`); + for (const e of status.envScan) { + console.log(` ${e.set ? "✓" : "·"} ${e.key}${e.set ? " (set)" : ""}`); + } + if (detected.length) { + console.log(`\n available providers (auto-detected):`); + for (const d of detected) { + console.log(` ${d.name.padEnd(18)} ${d.label.padEnd(24)} via ${d.source}`); + } + } + console.log(`\n Anthropic Console API key:`); + console.log(` 1. Go to console.anthropic.com/settings/keys`); + console.log(` 2. Create a key, then: export ANTHROPIC_API_KEY=sk-ant-...`); + console.log(`\n OpenRouter API key:`); + console.log(` 1. Go to openrouter.ai/keys`); + console.log(` 2. Create a key, then: export OPENROUTER_API_KEY=sk-or-...`); + console.log(`\n LiteLLM hosted gateway (no admin access needed):`); + console.log(` export LITELLM_BASE_URL=https://your-gateway.example.com`); + console.log(` export LITELLM_API_KEY=sk-... # or uses ANTHROPIC_API_KEY`); + console.log(`\n LiteLLM self-hosted gateway:`); + console.log(` ${BRAND.cli} config gateway # emit litellm.config.yaml`); + console.log(` ${BRAND.cli} config provider litellm`); + console.log(`\n quick start:`); + console.log(` ${BRAND.cli} config provider anthropic # direct Anthropic API`); + console.log(` ${BRAND.cli} config provider openrouter # OpenRouter multi-model`); + console.log(` ${BRAND.cli} config provider litellm # LiteLLM self-hosted`); + console.log(` ${BRAND.cli} config model sonnet # set default model tier`); + console.log(` ${BRAND.cli} route "" --apply # route + apply model`); + return; + } + console.error(`config: unknown subcommand "${sub}" (show | providers | provider | model | gateway | setup)`); process.exitCode = 1; return; } if (cmd === "route") { const r = await import("./route.js"); if (argv[1] === "gateway") { - const path = r.emitGatewayConfig(process.cwd()); - console.log(` wrote ${path} — LiteLLM tiers: forge-simple / forge-medium / forge-complex.`); + const result = r.emitGatewayConfig(process.cwd()); + if (typeof result === "object" && !result.ok) { + console.log(` ${result.reason}`); + return; + } + console.log(` wrote ${result} — LiteLLM tiers: forge-simple / forge-medium / forge-complex.`); console.log(" next: pin+install litellm, run it, point ANTHROPIC_BASE_URL at it, then"); console.log( " REQUEST the tier `forge route` recommends (a plain claude-* request passes through).", diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index 895eeb2..bf0b4e6 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -166,6 +166,12 @@ const TOOLS = [ "Health check — verify installed tools, guards, MCP auth, config drift, and system state.", inputSchema: { type: "object", properties: {} }, }, + { + name: "forge_provider_status", + description: + "Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks.", + inputSchema: { type: "object", properties: {} }, + }, ]; async function callTool(name, args = {}) { @@ -242,6 +248,25 @@ async function callTool(name, args = {}) { const { results, failed } = doctor({ targetRoot: root }); return JSON.stringify({ results, failed }, null, 2); } + if (name === "forge_provider_status") { + const { activeProvider, providerStatus, listDetectedProviders } = await import("./providers.js"); + const prov = activeProvider(root); + const status = providerStatus(root); + const detected = listDetectedProviders(); + return JSON.stringify({ + active: { + name: prov.name, + type: prov.type, + label: prov.label || prov.name, + baseUrl: prov.baseUrl, + autoDetected: Boolean(prov._autoDetected), + source: prov._source || null, + }, + checks: status.checks, + availableProviders: detected, + envScan: status.envScan, + }, null, 2); + } return null; } diff --git a/src/doctor.js b/src/doctor.js index 79b6104..7548243 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -9,6 +9,7 @@ import { summary as cortexSummary } from "./cortex.js"; import { extractHash, hashContent } from "./emit/_shared.js"; import { verify as ledgerVerify, repoLedger } from "./ledger_store.js"; import { PRICING_VERIFIED } from "./model_tiers.js"; +import { activeProvider } from "./providers.js"; import { canonical } from "./sync.js"; const ok = (label, note = "") => ({ status: "ok", label, note }); @@ -283,9 +284,21 @@ function checkLedger(out, targetRoot) { ); } +function checkProvider(out, targetRoot) { + const prov = activeProvider(targetRoot); + if (prov._autoDetected) { + out.push(ok("provider", `${prov.name} (auto-detected from ${prov._source})`)); + } else if (prov.envKey && !process.env[prov.envKey]) { + out.push(warn("provider", `${prov.name} — ${prov.envKey} is NOT set`)); + } else { + out.push(ok("provider", `${prov.name} (configured)`)); + } +} + export function doctor({ targetRoot = process.cwd() } = {}) { const results = []; checkNode(results); + checkProvider(results, targetRoot); checkBrandConsistency(results); checkLayers(results); checkGuardsExecutable(results); diff --git a/src/init.js b/src/init.js index d7c3641..39c201c 100644 --- a/src/init.js +++ b/src/init.js @@ -12,6 +12,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { BRAND } from "./brand.js"; import { GITATTRIBUTES_RULE } from "./ledger_store.js"; +import { autoDetectProvider } from "./providers.js"; import { sync } from "./sync.js"; import { list as tasteList } from "./taste.js"; @@ -145,7 +146,8 @@ export function init({ targetRoot = process.cwd(), noSettings = false } = {}) { const r = sync({ targetRoot }); ensureLedgerGitattributes(targetRoot); const settings = mergeSettings({ noSettings }); - return { ...r, settings }; + const detected = autoDetectProvider(); + return { ...r, settings, detected }; } function skillDescription(dir) { diff --git a/src/providers.js b/src/providers.js index 2a4463d..8eef2d6 100644 --- a/src/providers.js +++ b/src/providers.js @@ -2,6 +2,7 @@ // OpenRouter, and custom API endpoints. Config lives in .forge/providers.json; defaults // are hardcoded so the system works with zero config. Never stores API keys — only env // var names (the key is resolved at runtime from the environment). +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -10,6 +11,8 @@ import { MODELS } from "./model_tiers.js"; const PROVIDERS_FILE = "providers.json"; const PROVIDERS_DIR = ".forge"; +const ANTHROPIC_DEFAULT_URL = "https://api.anthropic.com"; + function providersPath(root) { return join(root, PROVIDERS_DIR, PROVIDERS_FILE); } @@ -33,6 +36,18 @@ const BUILTIN_PROVIDERS = { Object.entries(MODELS).map(([key, m]) => [key, `anthropic/${m.id}`]), ), }, + litellm: { + type: "litellm", + label: "LiteLLM Gateway", + baseUrl: "http://localhost:4000", + envKey: "ANTHROPIC_API_KEY", + models: { + haiku: "forge-simple", + sonnet: "forge-medium", + opus: "forge-complex", + fable: "forge-complex", + }, + }, }; function defaults() { @@ -68,13 +83,22 @@ function saveProviders(root, config) { writeFileSync(providersPath(root), `${JSON.stringify(config, null, 2)}\n`); } -/** The active provider config. */ +/** The active provider config. Explicit .forge/providers.json wins; otherwise auto-detects from env. */ export function activeProvider(root = process.cwd()) { - const config = loadProviders(root); - const name = config.active; - const provider = config.providers[name]; - if (!provider) return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic }; - return { name, ...provider }; + if (existsSync(providersPath(root))) { + const config = loadProviders(root); + const name = config.active; + const provider = config.providers[name]; + if (provider) return { name, ...provider }; + } + + const detected = autoDetectProvider(); + if (detected) { + const { source, ...rest } = detected; + return { ...rest, _autoDetected: true, _source: source }; + } + + return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic }; } /** Resolve a tier key (haiku/sonnet/opus/fable) to the active provider's model ID. */ @@ -132,6 +156,155 @@ export function listProviders(root = process.cwd()) { })); } +/** + * Auto-detect the best provider from environment variables. + * Pure read — never writes config. Returns null if nothing detected. + */ +export function autoDetectProvider() { + const litellmUrl = (process.env.LITELLM_BASE_URL || "").replace(/\/+$/, ""); + if (litellmUrl) { + return { + name: "litellm", + type: "litellm", + label: "LiteLLM Gateway (hosted)", + baseUrl: litellmUrl, + envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : "ANTHROPIC_API_KEY", + models: Object.fromEntries( + Object.entries(MODELS).map(([key, m]) => [key, m.id]), + ), + source: "LITELLM_BASE_URL", + }; + } + + const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, ""); + if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) { + return { + name: "anthropic-proxy", + type: "anthropic", + label: "Anthropic (via proxy)", + baseUrl, + envKey: "ANTHROPIC_API_KEY", + models: Object.fromEntries( + Object.entries(MODELS).map(([key, m]) => [key, m.id]), + ), + source: "ANTHROPIC_BASE_URL", + }; + } + + if (process.env.OPENROUTER_API_KEY) { + return { name: "openrouter", ...BUILTIN_PROVIDERS.openrouter, source: "OPENROUTER_API_KEY" }; + } + + if (process.env.ANTHROPIC_API_KEY) { + return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic, source: "ANTHROPIC_API_KEY" }; + } + + return null; +} + +/** List all providers that could be auto-detected from the current environment. */ +export function listDetectedProviders() { + const detected = []; + const litellmUrl = (process.env.LITELLM_BASE_URL || "").replace(/\/+$/, ""); + if (litellmUrl) { + detected.push({ + name: "litellm", + type: "litellm", + label: "LiteLLM Gateway (hosted)", + source: "LITELLM_BASE_URL", + available: true, + }); + } + const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, ""); + if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) { + detected.push({ + name: "anthropic-proxy", + type: "anthropic", + label: "Anthropic (via proxy)", + source: "ANTHROPIC_BASE_URL", + available: true, + }); + } + if (process.env.OPENROUTER_API_KEY) { + detected.push({ + name: "openrouter", + type: "openrouter", + label: "OpenRouter", + source: "OPENROUTER_API_KEY", + available: true, + }); + } + if (process.env.ANTHROPIC_API_KEY) { + detected.push({ + name: "anthropic", + type: "anthropic", + label: "Anthropic (direct)", + source: "ANTHROPIC_API_KEY", + available: true, + }); + } + return detected; +} + +/** Check provider health: env key set, gateway reachable (litellm), base URL valid. */ +export function providerStatus(root = process.cwd()) { + const prov = activeProvider(root); + const checks = []; + + if (prov._autoDetected) { + checks.push({ + id: "auto-detect", + ok: true, + detail: `Auto-detected from ${prov._source}`, + }); + } + + if (prov.envKey) { + checks.push({ + id: "api-key", + ok: Boolean(process.env[prov.envKey]), + detail: process.env[prov.envKey] + ? `${prov.envKey} is set` + : `${prov.envKey} is NOT set — get it from the Anthropic Console (console.anthropic.com/settings/keys) or your provider's dashboard`, + }); + } + if (prov.type === "litellm") { + let reachable = false; + try { + const out = execFileSync( + "curl", + ["-sf", "-o", "/dev/null", "-w", "%{http_code}", `${prov.baseUrl}/health`], + { encoding: "utf8", timeout: 5000 }, + ); + reachable = out.startsWith("2"); + } catch {} + checks.push({ + id: "gateway", + ok: reachable, + detail: reachable + ? `LiteLLM gateway reachable at ${prov.baseUrl}` + : `LiteLLM gateway NOT reachable at ${prov.baseUrl}`, + }); + } + + const envScan = [ + { key: "LITELLM_BASE_URL", set: Boolean(process.env.LITELLM_BASE_URL) }, + { key: "LITELLM_API_KEY", set: Boolean(process.env.LITELLM_API_KEY) }, + { key: "ANTHROPIC_BASE_URL", set: Boolean(process.env.ANTHROPIC_BASE_URL) }, + { key: "OPENROUTER_API_KEY", set: Boolean(process.env.OPENROUTER_API_KEY) }, + { key: "ANTHROPIC_API_KEY", set: Boolean(process.env.ANTHROPIC_API_KEY) }, + ]; + + return { + provider: prov.name, + type: prov.type, + autoDetected: Boolean(prov._autoDetected), + source: prov._source || null, + checks, + envScan, + }; +} + /** * Apply a routed model recommendation to ~/.claude/settings.json. * @param {string} tierKey - the recommended tier (haiku/sonnet/opus/fable) diff --git a/src/route.js b/src/route.js index 5e2e62e..ca86655 100644 --- a/src/route.js +++ b/src/route.js @@ -10,6 +10,7 @@ import { gitChurn, grepFanout } from "./cortex_features.js"; import { recordRoute } from "./cost_report.js"; import { mergedLessons } from "./ledger_read.js"; import { MODELS } from "./model_tiers.js"; +import { activeProvider } from "./providers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; import { clamp01, contentHash, epochDay } from "./util.js"; @@ -272,8 +273,21 @@ export function meterRoute(root, task, rec) { } catch {} } -/** Emit a LiteLLM config exposing the complexity tiers as aliases (request the one `forge route` picks). */ +/** Emit a LiteLLM config exposing the complexity tiers as aliases (request the one `forge route` picks). + * Provider-aware: uses the active provider's model IDs for the passthrough entries + * and the correct LiteLLM model prefix (anthropic/ for direct, openrouter/ for OR). + * Returns `{ ok: false, reason }` for hosted gateways the user cannot configure. */ export function emitGatewayConfig(root = process.cwd()) { + const prov = activeProvider(root); + if (prov._autoDetected && prov._source === "LITELLM_BASE_URL") { + return { + ok: false, + reason: `Hosted LiteLLM gateway detected at ${prov.baseUrl}. ` + + `No local config needed — requests go directly to the hosted gateway. ` + + `Use standard model names (the gateway handles routing).`, + }; + } + const prefix = prov.type === "openrouter" ? "openrouter/" : "anthropic/"; const path = join(root, "litellm.config.yaml"); const body = `# Forge Preflight — LiteLLM routing config (complexity tier -> model). # HOW ROUTING WORKS: LiteLLM routes by the REQUESTED model name; it cannot infer task @@ -282,22 +296,24 @@ export function emitGatewayConfig(root = process.cwd()) { # unchanged — pointing ANTHROPIC_BASE_URL here never breaks existing traffic. # pip install "litellm[proxy]==" # supply-chain: pin exact, no floating tag # litellm --config litellm.config.yaml # then export ANTHROPIC_BASE_URL=http://localhost:4000 +# Provider: ${prov.label || prov.name} (${prov.type}) # Models verified 2026-07-05; re-verify via dev-radar. model_list: # Tier aliases — request one of these (per 'forge route') to pick a model by complexity. - model_name: forge-simple # ${MODELS.haiku.name} — ${MODELS.haiku.use} - litellm_params: { model: anthropic/${MODELS.haiku.id} } + litellm_params: { model: ${prefix}${MODELS.haiku.id} } - model_name: forge-medium # ${MODELS.sonnet.name} — default - litellm_params: { model: anthropic/${MODELS.sonnet.id} } + litellm_params: { model: ${prefix}${MODELS.sonnet.id} } - model_name: forge-complex # ${MODELS.opus.name} - litellm_params: { model: anthropic/${MODELS.opus.id} } + litellm_params: { model: ${prefix}${MODELS.opus.id} } # Passthrough — a normal claude-* request still works when pointed at the gateway. - model_name: ${MODELS.haiku.id} - litellm_params: { model: anthropic/${MODELS.haiku.id} } + litellm_params: { model: ${prefix}${MODELS.haiku.id} } - model_name: ${MODELS.sonnet.id} - litellm_params: { model: anthropic/${MODELS.sonnet.id} } + litellm_params: { model: ${prefix}${MODELS.sonnet.id} } - model_name: ${MODELS.opus.id} - litellm_params: { model: anthropic/${MODELS.opus.id} } + litellm_params: { model: ${prefix}${MODELS.opus.id} } +${prov.envKey ? `litellm_settings:\n drop_params: true\n set_verbose: false` : ""} router_settings: routing_strategy: simple-shuffle `; diff --git a/test/providers.test.js b/test/providers.test.js index 27b814a..1364fa3 100644 --- a/test/providers.test.js +++ b/test/providers.test.js @@ -7,8 +7,11 @@ import { activeProvider, addProvider, applyRoute, + autoDetectProvider, + listDetectedProviders, listProviders, loadProviders, + providerStatus, resolveModel, setProvider, } from "../src/providers.js"; @@ -17,6 +20,33 @@ function tmpRoot() { return mkdtempSync(join(tmpdir(), "forge-prov-")); } +function withEnv(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +const CLEAR_ENV = { + LITELLM_BASE_URL: undefined, + LITELLM_API_KEY: undefined, + ANTHROPIC_BASE_URL: undefined, + OPENROUTER_API_KEY: undefined, + ANTHROPIC_API_KEY: undefined, +}; + +// --- existing tests --- + test("loadProviders returns defaults when no config file exists", () => { const root = tmpRoot(); const config = loadProviders(root); @@ -27,10 +57,12 @@ test("loadProviders returns defaults when no config file exists", () => { test("activeProvider returns anthropic by default", () => { const root = tmpRoot(); - const prov = activeProvider(root); - assert.equal(prov.name, "anthropic"); - assert.equal(prov.type, "anthropic"); - assert.ok(prov.models.haiku); + withEnv(CLEAR_ENV, () => { + const prov = activeProvider(root); + assert.equal(prov.name, "anthropic"); + assert.equal(prov.type, "anthropic"); + assert.ok(prov.models.haiku); + }); }); test("setProvider switches active provider", () => { @@ -125,3 +157,204 @@ test("loadProviders merges user overrides with defaults", () => { assert.equal(config.providers.anthropic.label, "My Anthropic"); assert.ok(config.providers.anthropic.baseUrl); }); + +// --- auto-detection tests --- + +test("autoDetectProvider returns null when no env vars set", () => { + withEnv(CLEAR_ENV, () => { + assert.equal(autoDetectProvider(), null); + }); +}); + +test("autoDetectProvider: ANTHROPIC_API_KEY → anthropic", () => { + withEnv({ ...CLEAR_ENV, ANTHROPIC_API_KEY: "sk-ant-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "anthropic"); + assert.equal(r.type, "anthropic"); + assert.equal(r.source, "ANTHROPIC_API_KEY"); + }); +}); + +test("autoDetectProvider: OPENROUTER_API_KEY → openrouter", () => { + withEnv({ ...CLEAR_ENV, OPENROUTER_API_KEY: "sk-or-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "openrouter"); + assert.equal(r.type, "openrouter"); + assert.equal(r.source, "OPENROUTER_API_KEY"); + }); +}); + +test("autoDetectProvider: LITELLM_BASE_URL → litellm with custom URL", () => { + withEnv({ ...CLEAR_ENV, LITELLM_BASE_URL: "https://litellm.company.com" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "litellm"); + assert.equal(r.type, "litellm"); + assert.equal(r.baseUrl, "https://litellm.company.com"); + assert.equal(r.source, "LITELLM_BASE_URL"); + assert.ok(r.models.haiku); + assert.ok(!r.models.haiku.startsWith("forge-")); + }); +}); + +test("autoDetectProvider: LITELLM_BASE_URL strips trailing slash", () => { + withEnv({ ...CLEAR_ENV, LITELLM_BASE_URL: "https://gw.example.com/" }, () => { + const r = autoDetectProvider(); + assert.equal(r.baseUrl, "https://gw.example.com"); + }); +}); + +test("autoDetectProvider: LITELLM_BASE_URL + LITELLM_API_KEY → correct envKey", () => { + withEnv({ + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + LITELLM_API_KEY: "sk-litellm-test", + }, () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "LITELLM_API_KEY"); + }); +}); + +test("autoDetectProvider: LITELLM_BASE_URL without LITELLM_API_KEY → falls back to ANTHROPIC_API_KEY envKey", () => { + withEnv({ + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + ANTHROPIC_API_KEY: "sk-ant-test", + }, () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "ANTHROPIC_API_KEY"); + }); +}); + +test("autoDetectProvider: ANTHROPIC_BASE_URL (non-default) → anthropic-proxy", () => { + withEnv({ ...CLEAR_ENV, ANTHROPIC_BASE_URL: "http://localhost:4000" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "anthropic-proxy"); + assert.equal(r.type, "anthropic"); + assert.equal(r.baseUrl, "http://localhost:4000"); + assert.equal(r.source, "ANTHROPIC_BASE_URL"); + }); +}); + +test("autoDetectProvider: ANTHROPIC_BASE_URL=https://api.anthropic.com → ignored (default)", () => { + withEnv({ ...CLEAR_ENV, ANTHROPIC_BASE_URL: "https://api.anthropic.com" }, () => { + assert.equal(autoDetectProvider(), null); + }); +}); + +test("autoDetectProvider: LITELLM_API_KEY alone (no URL) → ignored", () => { + withEnv({ ...CLEAR_ENV, LITELLM_API_KEY: "sk-litellm-test" }, () => { + assert.equal(autoDetectProvider(), null); + }); +}); + +test("autoDetectProvider: priority — LITELLM_BASE_URL wins over OPENROUTER_API_KEY", () => { + withEnv({ + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "litellm"); + assert.equal(r.source, "LITELLM_BASE_URL"); + }); +}); + +test("autoDetectProvider: priority — OPENROUTER_API_KEY wins over ANTHROPIC_API_KEY alone", () => { + withEnv({ + ...CLEAR_ENV, + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "openrouter"); + assert.equal(r.source, "OPENROUTER_API_KEY"); + }); +}); + +// --- activeProvider with auto-detection --- + +test("activeProvider: explicit config overrides auto-detection", () => { + const root = tmpRoot(); + const dir = join(root, ".forge"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "providers.json"), + JSON.stringify({ active: "anthropic", providers: {} }), + ); + withEnv({ OPENROUTER_API_KEY: "sk-or-test" }, () => { + const prov = activeProvider(root); + assert.equal(prov.name, "anthropic"); + assert.ok(!prov._autoDetected); + }); +}); + +test("activeProvider: auto-detects when no config file exists", () => { + const root = tmpRoot(); + withEnv({ ...CLEAR_ENV, OPENROUTER_API_KEY: "sk-or-test" }, () => { + const prov = activeProvider(root); + assert.equal(prov.name, "openrouter"); + assert.ok(prov._autoDetected); + assert.equal(prov._source, "OPENROUTER_API_KEY"); + }); +}); + +test("activeProvider: falls back to anthropic when nothing detected", () => { + const root = tmpRoot(); + withEnv(CLEAR_ENV, () => { + const prov = activeProvider(root); + assert.equal(prov.name, "anthropic"); + assert.ok(!prov._autoDetected); + }); +}); + +// --- providerStatus --- + +test("providerStatus: reports auto-detection source", () => { + const root = tmpRoot(); + withEnv({ ...CLEAR_ENV, OPENROUTER_API_KEY: "sk-or-test" }, () => { + const status = providerStatus(root); + assert.equal(status.autoDetected, true); + assert.equal(status.source, "OPENROUTER_API_KEY"); + assert.ok(status.checks.some((c) => c.id === "auto-detect")); + }); +}); + +test("providerStatus: envScan lists all relevant env vars", () => { + const root = tmpRoot(); + withEnv(CLEAR_ENV, () => { + const status = providerStatus(root); + assert.ok(Array.isArray(status.envScan)); + assert.ok(status.envScan.length >= 5); + const keys = status.envScan.map((e) => e.key); + assert.ok(keys.includes("LITELLM_BASE_URL")); + assert.ok(keys.includes("LITELLM_API_KEY")); + assert.ok(keys.includes("ANTHROPIC_BASE_URL")); + assert.ok(keys.includes("OPENROUTER_API_KEY")); + assert.ok(keys.includes("ANTHROPIC_API_KEY")); + }); +}); + +// --- listDetectedProviders --- + +test("listDetectedProviders: returns empty when no env vars set", () => { + withEnv(CLEAR_ENV, () => { + assert.deepEqual(listDetectedProviders(), []); + }); +}); + +test("listDetectedProviders: returns all available providers from env", () => { + withEnv({ + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, () => { + const detected = listDetectedProviders(); + assert.ok(detected.length >= 3); + const names = detected.map((d) => d.name); + assert.ok(names.includes("litellm")); + assert.ok(names.includes("openrouter")); + assert.ok(names.includes("anthropic")); + }); +}); From c658c65dadcf02f71c701daa490ddc55ed518fc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:44:52 +0000 Subject: [PATCH 2/3] style: fix biome 2.5.2 lint issues across source and test files Apply biome auto-fixes: import ordering (dash.js, cost_report.js), catch block formatting (cost_report.js), string concatenation to template literals (cli.js, dash.html), optional chaining (cli.js), and import/code reorganization across modified files. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/cli.js | 76 ++++++++++++++++++++------- src/cortex_mcp.js | 80 ++++++++++++++++++----------- src/cost_report.js | 13 +++-- src/dash.html | 6 +-- src/dash.js | 2 +- src/init.js | 8 +-- src/providers.js | 21 +++----- src/route.js | 5 +- test/providers.test.js | 113 +++++++++++++++++++++++------------------ 9 files changed, 198 insertions(+), 126 deletions(-) diff --git a/src/cli.js b/src/cli.js index 4936f01..af8111e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -77,9 +77,7 @@ async function run(argv) { ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, ); if (settings?.action === "merged") { - console.log( - ` settings: merged ${settings.added.join(", ")} into ${settings.path}`, - ); + console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`); } else if (settings?.action === "unchanged") { console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { @@ -88,7 +86,9 @@ async function run(argv) { if (detected) { console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); } else { - console.log(` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`); + console.log( + ` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`, + ); } console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``); console.log(` verify: \`${BRAND.cli} doctor\``); @@ -697,9 +697,14 @@ async function run(argv) { const { estimateSpendFromLogs } = await import("./cost_report.js"); const est = estimateSpendFromLogs(); if (est && est.totalCost > 0) { - console.log(` $${est.totalCost.toFixed(2)} estimated from Claude session logs (${est.sessions} session(s))`); + console.log( + ` $${est.totalCost.toFixed(2)} estimated from Claude session logs (${est.sessions} session(s))`, + ); if (est.byModel.length) { - for (const m of est.byModel) console.log(` ${m.model.padEnd(30)} $${m.cost.toFixed(4)} (${m.inTokens} in / ${m.outTokens} out)`); + for (const m of est.byModel) + console.log( + ` ${m.model.padEnd(30)} $${m.cost.toFixed(4)} (${m.inTokens} in / ${m.outTokens} out)`, + ); } console.log("\n install ccusage for precise tracking: npm i -g ccusage"); } else { @@ -866,12 +871,15 @@ async function run(argv) { if (sub === "show") { const prov = activeProvider(process.cwd()); const config = loadProviders(process.cwd()); - if (json) return console.log(JSON.stringify({ active: config.active, provider: prov }, null, 2)); + if (json) + return console.log(JSON.stringify({ active: config.active, provider: prov }, null, 2)); console.log(`${BRAND.brand} config\n`); console.log(` provider: ${prov.name} (${prov.label || prov.name})`); if (prov._autoDetected) console.log(` detected: auto (from ${prov._source})`); console.log(` base URL: ${prov.baseUrl}`); - console.log(` env key: ${prov.envKey || "(none)"}${prov.envKey ? (process.env[prov.envKey] ? " ✓ set" : " ✗ not set") : ""}`); + console.log( + ` env key: ${prov.envKey || "(none)"}${prov.envKey ? (process.env[prov.envKey] ? " ✓ set" : " ✗ not set") : ""}`, + ); console.log(` models:`); for (const [tier, id] of Object.entries(prov.models || {})) console.log(` ${tier.padEnd(8)} ${id}`); @@ -891,23 +899,36 @@ async function run(argv) { if (sub === "provider") { const name = argv[2]; if (!name) { - console.error(`usage: ${BRAND.cli} config provider | ${BRAND.cli} config provider add --base-url [--key-env ]`); + console.error( + `usage: ${BRAND.cli} config provider | ${BRAND.cli} config provider add --base-url [--key-env ]`, + ); process.exitCode = 1; return; } if (name === "add") { const addName = argv[3]; - const flagVal = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }; + const flagVal = (f) => { + const i = argv.indexOf(f); + return i >= 0 ? argv[i + 1] : undefined; + }; const baseUrl = flagVal("--base-url"); const envKey = flagVal("--key-env"); const label = flagVal("--label"); const r = addProvider(process.cwd(), addName, { baseUrl, envKey, label }); - if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + if (!r.ok) { + console.error(` ${r.reason}`); + process.exitCode = 1; + return; + } console.log(` added provider "${addName}" → ${r.provider.baseUrl}`); return; } const r = setProvider(process.cwd(), name); - if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + if (!r.ok) { + console.error(` ${r.reason}`); + process.exitCode = 1; + return; + } console.log(` switched to provider "${name}" (${r.provider.label || name})`); return; } @@ -919,7 +940,11 @@ async function run(argv) { return; } const r = applyRoute(tier); - if (!r.ok) { console.error(` ${r.reason}`); process.exitCode = 1; return; } + if (!r.ok) { + console.error(` ${r.reason}`); + process.exitCode = 1; + return; + } console.log(` model set to ${r.model} (${r.modelId})${r.prev ? ` — was: ${r.prev}` : ""}`); console.log(` written to ${r.path}`); return; @@ -983,7 +1008,9 @@ async function run(argv) { console.log(` ${BRAND.cli} route "" --apply # route + apply model`); return; } - console.error(`config: unknown subcommand "${sub}" (show | providers | provider | model | gateway | setup)`); + console.error( + `config: unknown subcommand "${sub}" (show | providers | provider | model | gateway | setup)`, + ); process.exitCode = 1; return; } @@ -995,7 +1022,9 @@ async function run(argv) { console.log(` ${result.reason}`); return; } - console.log(` wrote ${result} — LiteLLM tiers: forge-simple / forge-medium / forge-complex.`); + console.log( + ` wrote ${result} — LiteLLM tiers: forge-simple / forge-medium / forge-complex.`, + ); console.log(" next: pin+install litellm, run it, point ANTHROPIC_BASE_URL at it, then"); console.log( " REQUEST the tier `forge route` recommends (a plain claude-* request passes through).", @@ -1012,14 +1041,20 @@ async function run(argv) { .filter((a, i) => !FLAGS.has(a) && a !== "--provider" && argv[i] !== "--provider") .join(" "); if (!task) { - console.error('usage: forge route "" [--apply] [--provider ] [--json] | forge route gateway'); + console.error( + 'usage: forge route "" [--apply] [--provider ] [--json] | forge route gateway', + ); process.exitCode = 1; return; } if (providerName) { const { setProvider } = await import("./providers.js"); const sr = setProvider(process.cwd(), providerName); - if (!sr.ok) { console.error(` ${sr.reason}`); process.exitCode = 1; return; } + if (!sr.ok) { + console.error(` ${sr.reason}`); + process.exitCode = 1; + return; + } } const rec = r.routeTask(process.cwd(), task); r.meterRoute(process.cwd(), task, rec); @@ -1042,13 +1077,16 @@ async function run(argv) { const { applyRoute } = await import("./providers.js"); const ar = applyRoute(rec.key); if (ar.ok) { - if (!json) console.log(`\n applied: model set to ${ar.model} (${ar.modelId}) in ${ar.path}`); + if (!json) + console.log(`\n applied: model set to ${ar.model} (${ar.modelId}) in ${ar.path}`); } else { if (!json) console.error(`\n apply failed: ${ar.reason}`); process.exitCode = 1; } } else if (!json) { - console.log(`\n advisory · apply: \`${BRAND.cli} route "" --apply\` · gateway: \`${BRAND.cli} route gateway\``); + console.log( + `\n advisory · apply: \`${BRAND.cli} route "" --apply\` · gateway: \`${BRAND.cli} route gateway\``, + ); } return; } diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index bf0b4e6..9a04e47 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -7,15 +7,15 @@ import { argv } from "node:process"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { lessonsForContext, summary } from "./cortex.js"; +import { report as costReport } from "./cost_report.js"; +import { dashData } from "./dash.js"; +import { diagnose } from "./diagnose.js"; +import { doctor } from "./doctor.js"; import { assessTask, clarifyBlock, preflightRepo } from "./preflight.js"; import { routeTask } from "./route.js"; import { decompose } from "./scope.js"; import { predictImpact, substrateCheck } from "./substrate.js"; import { epochDay } from "./util.js"; -import { report as costReport } from "./cost_report.js"; -import { dashData } from "./dash.js"; -import { diagnose } from "./diagnose.js"; -import { doctor } from "./doctor.js"; const PKG_VERSION = (() => { try { @@ -132,8 +132,7 @@ const TOOLS = [ }, { name: "forge_brain", - description: - "Project memory index — list all remembered facts stored in .forge/brain/.", + description: "Project memory index — list all remembered facts stored in .forge/brain/.", inputSchema: { type: "object", properties: {} }, }, { @@ -142,7 +141,9 @@ const TOOLS = [ "Query the proof-carrying memory ledger with a natural language query. Returns ranked matching claims.", inputSchema: { type: "object", - properties: { query: { type: "string", description: "what you are about to do or looking for" } }, + properties: { + query: { type: "string", description: "what you are about to do or looking for" }, + }, required: ["query"], }, }, @@ -218,7 +219,9 @@ async function callTool(name, args = {}) { const idx = buildIndex(store); const items = list(store); return JSON.stringify({ items, indexed: idx.indexed, overflow: idx.overflow }, null, 2); - } catch { return "No brain store found — run `forge remember` to start."; } + } catch { + return "No brain store found — run `forge remember` to start."; + } } if (name === "forge_ledger_query") { try { @@ -230,11 +233,22 @@ async function callTool(name, args = {}) { const claims = loadClaims(dir); const sim = claimSim(root, q, claims, claimText); const ranked = retrieve(q, claims, { nowDay: today(), budget: 8, sim }); - return JSON.stringify({ - sim: simLabel(sim), - results: ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score, text: claimText(r.claim).slice(0, 200) })), - }, null, 2); - } catch { return "No ledger claims found."; } + return JSON.stringify( + { + sim: simLabel(sim), + results: ranked.map((r) => ({ + id: r.claim.id, + kind: r.claim.kind, + score: r.score, + text: claimText(r.claim).slice(0, 200), + })), + }, + null, + 2, + ); + } catch { + return "No ledger claims found."; + } } if (name === "forge_diagnose") { const r = diagnose(root, { @@ -249,23 +263,29 @@ async function callTool(name, args = {}) { return JSON.stringify({ results, failed }, null, 2); } if (name === "forge_provider_status") { - const { activeProvider, providerStatus, listDetectedProviders } = await import("./providers.js"); + const { activeProvider, providerStatus, listDetectedProviders } = await import( + "./providers.js" + ); const prov = activeProvider(root); const status = providerStatus(root); const detected = listDetectedProviders(); - return JSON.stringify({ - active: { - name: prov.name, - type: prov.type, - label: prov.label || prov.name, - baseUrl: prov.baseUrl, - autoDetected: Boolean(prov._autoDetected), - source: prov._source || null, + return JSON.stringify( + { + active: { + name: prov.name, + type: prov.type, + label: prov.label || prov.name, + baseUrl: prov.baseUrl, + autoDetected: Boolean(prov._autoDetected), + source: prov._source || null, + }, + checks: status.checks, + availableProviders: detected, + envScan: status.envScan, }, - checks: status.checks, - availableProviders: detected, - envScan: status.envScan, - }, null, 2); + null, + 2, + ); } return null; } @@ -319,9 +339,11 @@ export function serve(input = process.stdin, output = process.stdout) { } catch { return; // ignore malformed frames } - handle(msg).then((res) => { - if (res) output.write(`${JSON.stringify(res)}\n`); - }).catch(() => {}); + handle(msg) + .then((res) => { + if (res) output.write(`${JSON.stringify(res)}\n`); + }) + .catch(() => {}); }); } diff --git a/src/cost_report.js b/src/cost_report.js index fa0f4b9..4d02a43 100644 --- a/src/cost_report.js +++ b/src/cost_report.js @@ -227,7 +227,11 @@ export function estimateSpendFromLogs() { for (const project of readdirSync(projectsDir)) { const pDir = join(projectsDir, project); let files; - try { files = readdirSync(pDir); } catch { continue; } + try { + files = readdirSync(pDir); + } catch { + continue; + } for (const f of files) { if (!f.endsWith(".jsonl")) continue; sessions++; @@ -254,11 +258,14 @@ export function estimateSpendFromLogs() { const modelBreakdown = []; for (const [model, usage] of Object.entries(byModel)) { const pricing = pricingPerM[model] || { inCost: 3, outCost: 15 }; - const cost = (usage.inTokens * pricing.inCost + usage.outTokens * pricing.outCost) / 1_000_000; + const cost = + (usage.inTokens * pricing.inCost + usage.outTokens * pricing.outCost) / 1_000_000; totalCost += cost; modelBreakdown.push({ model, cost, inTokens: usage.inTokens, outTokens: usage.outTokens }); } modelBreakdown.sort((a, b) => b.cost - a.cost); return { totalCost, sessions, byModel: modelBreakdown }; - } catch { return null; } + } catch { + return null; + } } diff --git a/src/dash.html b/src/dash.html index af7420d..feaee49 100644 --- a/src/dash.html +++ b/src/dash.html @@ -290,11 +290,9 @@

Impact — blast radius .forge/atlas.json

if (spend && spend.byModel && spend.byModel.length) { $("spend-section").innerHTML = '

Spend by model

' + '' + - spend.byModel.map(function(m) { - return ''; - }).join('') + '
modelcostin tokout tok
' + esc(m.model) + '$' + m.cost.toFixed(4) + + spend.byModel.map((m) => '
' + esc(m.model) + '$' + m.cost.toFixed(4) + '' + (m.inTokens || 0).toLocaleString() + - '' + (m.outTokens || 0).toLocaleString() + '
'; + '' + (m.outTokens || 0).toLocaleString() + '').join('') + ''; } else { $("spend-section").innerHTML = ''; } diff --git a/src/dash.js b/src/dash.js index cc8d95d..fbbea94 100644 --- a/src/dash.js +++ b/src/dash.js @@ -12,6 +12,7 @@ import { createServer } from "node:http"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { impact, load as loadAtlas } from "./atlas.js"; +import { estimateSpendFromLogs } from "./cost_report.js"; import { authorTrust, claimText, val, validOutcome } from "./ledger.js"; import { getClaimByPrefix, @@ -22,7 +23,6 @@ import { tombstone, } from "./ledger_store.js"; import { read as readMetrics, summarize } from "./metrics.js"; -import { estimateSpendFromLogs } from "./cost_report.js"; import { epochDay, gitAuthor } from "./util.js"; /** A claim is contested when its val sits in this band AND it carries ≥1 oracle diff --git a/src/init.js b/src/init.js index 39c201c..7e4f9b1 100644 --- a/src/init.js +++ b/src/init.js @@ -63,15 +63,11 @@ function mergeHooks(existing = {}, template = {}) { for (const [event, entries] of Object.entries(template)) { const existingEntries = merged[event] || []; const existingCommands = new Set( - existingEntries - .flatMap((e) => (e.hooks || []).map((h) => h.command)) - .filter(Boolean), + existingEntries.flatMap((e) => (e.hooks || []).map((h) => h.command)).filter(Boolean), ); const newEntries = []; for (const entry of entries) { - const hooks = (entry.hooks || []).filter( - (h) => !existingCommands.has(h.command), - ); + const hooks = (entry.hooks || []).filter((h) => !existingCommands.has(h.command)); if (hooks.length) { newEntries.push({ ...entry, hooks }); } diff --git a/src/providers.js b/src/providers.js index 8eef2d6..7d77490 100644 --- a/src/providers.js +++ b/src/providers.js @@ -23,9 +23,7 @@ const BUILTIN_PROVIDERS = { label: "Anthropic (direct)", baseUrl: "https://api.anthropic.com", envKey: "ANTHROPIC_API_KEY", - models: Object.fromEntries( - Object.entries(MODELS).map(([key, m]) => [key, m.id]), - ), + models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), }, openrouter: { type: "openrouter", @@ -111,7 +109,10 @@ export function resolveModel(root, tierKey) { export function setProvider(root, name) { const config = loadProviders(root); if (!config.providers[name]) { - return { ok: false, reason: `unknown provider "${name}" — add it first with forge config provider add` }; + return { + ok: false, + reason: `unknown provider "${name}" — add it first with forge config provider add`, + }; } config.active = name; saveProviders(root, config); @@ -134,9 +135,7 @@ export function addProvider(root, name, cfg) { label: cfg.label || name, baseUrl: cfg.baseUrl, envKey: cfg.envKey || "", - models: cfg.models || Object.fromEntries( - Object.entries(MODELS).map(([key, m]) => [key, m.id]), - ), + models: cfg.models || Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), }; saveProviders(root, config); return { ok: true, name, provider: config.providers[name] }; @@ -169,9 +168,7 @@ export function autoDetectProvider() { label: "LiteLLM Gateway (hosted)", baseUrl: litellmUrl, envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : "ANTHROPIC_API_KEY", - models: Object.fromEntries( - Object.entries(MODELS).map(([key, m]) => [key, m.id]), - ), + models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), source: "LITELLM_BASE_URL", }; } @@ -184,9 +181,7 @@ export function autoDetectProvider() { label: "Anthropic (via proxy)", baseUrl, envKey: "ANTHROPIC_API_KEY", - models: Object.fromEntries( - Object.entries(MODELS).map(([key, m]) => [key, m.id]), - ), + models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), source: "ANTHROPIC_BASE_URL", }; } diff --git a/src/route.js b/src/route.js index ca86655..c60db5d 100644 --- a/src/route.js +++ b/src/route.js @@ -10,8 +10,8 @@ import { gitChurn, grepFanout } from "./cortex_features.js"; import { recordRoute } from "./cost_report.js"; import { mergedLessons } from "./ledger_read.js"; import { MODELS } from "./model_tiers.js"; -import { activeProvider } from "./providers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; +import { activeProvider } from "./providers.js"; import { clamp01, contentHash, epochDay } from "./util.js"; // Weights sum to 1. Each raw signal is normalized by the point where it reads as "complex". @@ -282,7 +282,8 @@ export function emitGatewayConfig(root = process.cwd()) { if (prov._autoDetected && prov._source === "LITELLM_BASE_URL") { return { ok: false, - reason: `Hosted LiteLLM gateway detected at ${prov.baseUrl}. ` + + reason: + `Hosted LiteLLM gateway detected at ${prov.baseUrl}. ` + `No local config needed — requests go directly to the hosted gateway. ` + `Use standard model names (the gateway handles routing).`, }; diff --git a/test/providers.test.js b/test/providers.test.js index 1364fa3..a13f60d 100644 --- a/test/providers.test.js +++ b/test/providers.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -204,25 +204,31 @@ test("autoDetectProvider: LITELLM_BASE_URL strips trailing slash", () => { }); test("autoDetectProvider: LITELLM_BASE_URL + LITELLM_API_KEY → correct envKey", () => { - withEnv({ - ...CLEAR_ENV, - LITELLM_BASE_URL: "https://gw.example.com", - LITELLM_API_KEY: "sk-litellm-test", - }, () => { - const r = autoDetectProvider(); - assert.equal(r.envKey, "LITELLM_API_KEY"); - }); + withEnv( + { + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + LITELLM_API_KEY: "sk-litellm-test", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "LITELLM_API_KEY"); + }, + ); }); test("autoDetectProvider: LITELLM_BASE_URL without LITELLM_API_KEY → falls back to ANTHROPIC_API_KEY envKey", () => { - withEnv({ - ...CLEAR_ENV, - LITELLM_BASE_URL: "https://gw.example.com", - ANTHROPIC_API_KEY: "sk-ant-test", - }, () => { - const r = autoDetectProvider(); - assert.equal(r.envKey, "ANTHROPIC_API_KEY"); - }); + withEnv( + { + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + ANTHROPIC_API_KEY: "sk-ant-test", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "ANTHROPIC_API_KEY"); + }, + ); }); test("autoDetectProvider: ANTHROPIC_BASE_URL (non-default) → anthropic-proxy", () => { @@ -248,28 +254,34 @@ test("autoDetectProvider: LITELLM_API_KEY alone (no URL) → ignored", () => { }); test("autoDetectProvider: priority — LITELLM_BASE_URL wins over OPENROUTER_API_KEY", () => { - withEnv({ - ...CLEAR_ENV, - LITELLM_BASE_URL: "https://gw.example.com", - OPENROUTER_API_KEY: "sk-or-test", - ANTHROPIC_API_KEY: "sk-ant-test", - }, () => { - const r = autoDetectProvider(); - assert.equal(r.name, "litellm"); - assert.equal(r.source, "LITELLM_BASE_URL"); - }); + withEnv( + { + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.name, "litellm"); + assert.equal(r.source, "LITELLM_BASE_URL"); + }, + ); }); test("autoDetectProvider: priority — OPENROUTER_API_KEY wins over ANTHROPIC_API_KEY alone", () => { - withEnv({ - ...CLEAR_ENV, - OPENROUTER_API_KEY: "sk-or-test", - ANTHROPIC_API_KEY: "sk-ant-test", - }, () => { - const r = autoDetectProvider(); - assert.equal(r.name, "openrouter"); - assert.equal(r.source, "OPENROUTER_API_KEY"); - }); + withEnv( + { + ...CLEAR_ENV, + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.name, "openrouter"); + assert.equal(r.source, "OPENROUTER_API_KEY"); + }, + ); }); // --- activeProvider with auto-detection --- @@ -344,17 +356,20 @@ test("listDetectedProviders: returns empty when no env vars set", () => { }); test("listDetectedProviders: returns all available providers from env", () => { - withEnv({ - ...CLEAR_ENV, - LITELLM_BASE_URL: "https://gw.example.com", - OPENROUTER_API_KEY: "sk-or-test", - ANTHROPIC_API_KEY: "sk-ant-test", - }, () => { - const detected = listDetectedProviders(); - assert.ok(detected.length >= 3); - const names = detected.map((d) => d.name); - assert.ok(names.includes("litellm")); - assert.ok(names.includes("openrouter")); - assert.ok(names.includes("anthropic")); - }); + withEnv( + { + ...CLEAR_ENV, + LITELLM_BASE_URL: "https://gw.example.com", + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "sk-ant-test", + }, + () => { + const detected = listDetectedProviders(); + assert.ok(detected.length >= 3); + const names = detected.map((d) => d.name); + assert.ok(names.includes("litellm")); + assert.ok(names.includes("openrouter")); + assert.ok(names.includes("anthropic")); + }, + ); }); From 1243321be2e51163fb9e2f6368b52c816aa676bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:50:56 +0000 Subject: [PATCH 3/3] fix: resolve TypeScript errors and biome lint warnings Narrow union type in cli.js init handler with 'in' checks so TS can verify .added/.path access. Convert remaining string concatenations to template literals in cli.js and dash.html. Use optional chaining for r.findings?.length. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/cli.js | 6 +++--- src/dash.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli.js b/src/cli.js index af8111e..a36a167 100755 --- a/src/cli.js +++ b/src/cli.js @@ -76,9 +76,9 @@ async function run(argv) { console.log( ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, ); - if (settings?.action === "merged") { + if (settings?.action === "merged" && "added" in settings) { console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`); - } else if (settings?.action === "unchanged") { + } else if (settings?.action === "unchanged" && "path" in settings) { console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); @@ -593,7 +593,7 @@ async function run(argv) { } const r = scan(target); console.log(`${BRAND.brand} scan — skill-gate (${r.scanner})\n`); - if (r.findings && r.findings.length) { + if (r.findings?.length) { for (const f of r.findings) console.log(` [${f.sev}] ${f.msg}`); } else if (r.raw) { console.log(" " + r.raw.trim().split("\n").slice(-6).join("\n ")); diff --git a/src/dash.html b/src/dash.html index feaee49..d860d68 100644 --- a/src/dash.html +++ b/src/dash.html @@ -354,17 +354,17 @@

Impact — blast radius .forge/atlas.json

async function runWrite(act, id8) { const body = { id: id8 }; if (act === "retract") { - const reason = prompt("retract " + id8 + " — reason (recorded in the tombstone):"); + const reason = prompt(`retract ${id8} — reason (recorded in the tombstone):`); if (reason === null) return; // cancelled body.reason = reason; } - const res = await fetch("/api/" + act, { + const res = await fetch(`/api/${act}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const r = await res.json().catch(() => ({})); - if (!res.ok) alert(act + " failed: " + (r.reason || r.error || res.status)); + if (!res.ok) alert(`${act} failed: ${r.reason || r.error || res.status}`); await refresh(); }