Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/adjudicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
// - FAIL-SAFE. Any error/timeout/garble/secret → returns null. A null NEVER changes a verdict.
// - ZERO-DEP. Access is a `claude -p` CLI shell-out; the runner is injectable so the pure
// prompt/parse/verify logic is fully testable without the CLI or the network.
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { buildHttpRunner as httpRunner } from "./llm.js";
import { MODELS } from "./model_tiers.js";
import { envModelOverride } from "./providers.js";
import { SECRET_RE } from "./recall.js";

/**
Expand All @@ -22,10 +25,30 @@ export function llmEnabled(opts = {}) {
return process.env.FORGE_LLM === "1";
}

/** Build an injectable `claude -p` runner. Pure config in, a (prompt)->string runner out. */
let _claudeChecked = false;
let _claudeAvail = false;
function hasClaude() {
if (!_claudeChecked) {
_claudeChecked = true;
try {
const r = spawnSync("which", ["claude"], { encoding: "utf8", timeout: 2000, stdio: "pipe" });
_claudeAvail = r.status === 0;
} catch {
_claudeAvail = false;
}
}
return _claudeAvail;
}

/** Build an injectable LLM runner. Tries direct HTTP when `claude` CLI is unavailable
* or when FORGE_LLM_HTTP=1. Falls back to `claude -p` otherwise. */
export function buildRunner({ model = "haiku", timeoutMs = 20000 } = {}) {
const resolvedModel = envModelOverride() || MODELS[model]?.id || model;
if (process.env.FORGE_LLM_HTTP === "1" || !hasClaude()) {
return httpRunner({ model: resolvedModel, timeoutMs });
}
return (prompt) =>
execFileSync("claude", ["-p", "--model", model], {
execFileSync("claude", ["-p", "--model", resolvedModel], {
input: prompt,
encoding: "utf8",
timeout: timeoutMs,
Expand Down
14 changes: 13 additions & 1 deletion src/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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 { activeProvider, envModelOverride } from "./providers.js";
import { canonical } from "./sync.js";

const ok = (label, note = "") => ({ status: "ok", label, note });
Expand All @@ -31,6 +31,14 @@ function checkTooling(out) {
out.push(
hasBin("git") ? ok("git", "found") : warn("git", "not found — churn/impact/anchor need it"),
);
out.push(
hasBin("claude")
? ok("claude CLI", "found — LLM proposer uses it (FORGE_LLM=1)")
: warn(
"claude CLI",
"not found — LLM proposer falls back to direct HTTP (needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN)",
),
);
}

// Every guard the manifests reference must exist and be executable, or a hook silently no-ops.
Expand Down Expand Up @@ -293,6 +301,10 @@ function checkProvider(out, targetRoot) {
} else {
out.push(ok("provider", `${prov.name} (configured)`));
}
const override = envModelOverride();
if (override) {
out.push(ok("model override", `${override} (all tiers resolve to this model)`));
}
}

export function doctor({ targetRoot = process.cwd() } = {}) {
Expand Down
55 changes: 55 additions & 0 deletions src/llm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// forge llm — direct HTTP LLM call (Anthropic Messages API). Follows the embed.js
// child-process-fetch pattern: synchronous shell-out to a spawned node child so this
// module stays synchronous like every other forge faculty. The auth token travels via
// the child's env (_FORGE_LLM_KEY) — never in argv, never logged.
import { spawnSync } from "node:child_process";

const HTTP_CHILD = `let raw="";process.stdin.on("data",(d)=>{raw+=d;});process.stdin.on("end",async()=>{try{const{url,model,prompt,maxTokens}=JSON.parse(raw);const key=process.env._FORGE_LLM_KEY||"";const headers={"content-type":"application/json","anthropic-version":"2023-06-01"};if(key.startsWith("Bearer "))headers.authorization=key;else if(key)headers["x-api-key"]=key;const body=JSON.stringify({model,max_tokens:maxTokens||1024,messages:[{role:"user",content:prompt}]});const res=await fetch(url,{method:"POST",headers,body});if(!res.ok){process.stderr.write("llm: http "+res.status);process.exit(1);}const data=await res.json();const text=(data.content||[]).filter(b=>b.type==="text").map(b=>b.text).join("");process.stdout.write(text);}catch(e){process.stderr.write("llm: "+(e.message||e));process.exit(1);}});`;

/** Resolve base URL, auth key, and model override from environment. Returns null if no auth is available. */
export function resolveHttpProvider() {
const key =
process.env.ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_AUTH_TOKEN ||
process.env.LITELLM_API_KEY ||
"";
if (!key) return null;
const baseUrl = (
process.env.LITELLM_BASE_URL ||
process.env.ANTHROPIC_BASE_URL ||
"https://api.anthropic.com"
).replace(/\/+$/, "");
const model = process.env.ANTHROPIC_MODEL?.trim() || null;
return { baseUrl, key, model };
}

/**
* Build an HTTP-based LLM runner (Anthropic Messages API).
* Same contract as adjudicate.buildRunner: returns (prompt) => string.
* @param {{model?: string, timeoutMs?: number}} [opts]
*/
export function buildHttpRunner({ model = "claude-haiku-4-5-20251001", timeoutMs = 20000 } = {}) {
return (prompt) => {
const provider = resolveHttpProvider();
if (!provider)
throw new Error("no LLM provider configured — set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN");
const input = JSON.stringify({
url: `${provider.baseUrl}/v1/messages`,
model: provider.model || model,
prompt,
maxTokens: 1024,
});
const r = spawnSync(process.execPath, ["-e", HTTP_CHILD], {
input,
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, _FORGE_LLM_KEY: provider.key },
stdio: ["pipe", "pipe", "pipe"],
});
if (r.error || r.status !== 0 || !r.stdout) {
throw new Error(r.stderr?.trim() || r.error?.message || "llm call failed");
}
return r.stdout;
};
}
55 changes: 47 additions & 8 deletions src/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ const PROVIDERS_DIR = ".forge";

const ANTHROPIC_DEFAULT_URL = "https://api.anthropic.com";

function anthropicKeyEnv() {
if (process.env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
if (process.env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
return "ANTHROPIC_API_KEY";
}

function isLikelyGateway(url) {
return /\b(gateway|litellm|aigateway|llmproxy|llm-proxy)\b/i.test(url);
}

function providersPath(root) {
return join(root, PROVIDERS_DIR, PROVIDERS_FILE);
}
Expand Down Expand Up @@ -99,8 +109,15 @@ export function activeProvider(root = process.cwd()) {
return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic };
}

/** Explicit model override from the environment — bypasses tier-based routing when set. */
export function envModelOverride() {
return process.env.ANTHROPIC_MODEL?.trim() || process.env.FORGE_MODEL?.trim() || null;
}

/** Resolve a tier key (haiku/sonnet/opus/fable) to the active provider's model ID. */
export function resolveModel(root, tierKey) {
const override = envModelOverride();
if (override) return override;
const provider = activeProvider(root);
return provider.models?.[tierKey] ?? MODELS[tierKey]?.id ?? null;
}
Expand Down Expand Up @@ -167,20 +184,21 @@ export function autoDetectProvider() {
type: "litellm",
label: "LiteLLM Gateway (hosted)",
baseUrl: litellmUrl,
envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : "ANTHROPIC_API_KEY",
envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : anthropicKeyEnv(),
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) {
const gateway = isLikelyGateway(baseUrl);
return {
name: "anthropic-proxy",
type: "anthropic",
label: "Anthropic (via proxy)",
name: gateway ? "litellm-gateway" : "anthropic-proxy",
type: gateway ? "litellm" : "anthropic",
label: gateway ? "LiteLLM Gateway (via ANTHROPIC_BASE_URL)" : "Anthropic (via proxy)",
baseUrl,
envKey: "ANTHROPIC_API_KEY",
envKey: anthropicKeyEnv(),
models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])),
source: "ANTHROPIC_BASE_URL",
};
Expand All @@ -194,6 +212,15 @@ export function autoDetectProvider() {
return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic, source: "ANTHROPIC_API_KEY" };
}

if (process.env.ANTHROPIC_AUTH_TOKEN) {
return {
name: "anthropic",
...BUILTIN_PROVIDERS.anthropic,
envKey: "ANTHROPIC_AUTH_TOKEN",
source: "ANTHROPIC_AUTH_TOKEN",
};
}

return null;
}

Expand All @@ -212,10 +239,11 @@ export function listDetectedProviders() {
}
const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, "");
if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) {
const gateway = isLikelyGateway(baseUrl);
detected.push({
name: "anthropic-proxy",
type: "anthropic",
label: "Anthropic (via proxy)",
name: gateway ? "litellm-gateway" : "anthropic-proxy",
type: gateway ? "litellm" : "anthropic",
label: gateway ? "LiteLLM Gateway (via ANTHROPIC_BASE_URL)" : "Anthropic (via proxy)",
source: "ANTHROPIC_BASE_URL",
available: true,
});
Expand All @@ -238,6 +266,15 @@ export function listDetectedProviders() {
available: true,
});
}
if (!process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_AUTH_TOKEN) {
detected.push({
name: "anthropic",
type: "anthropic",
label: "Anthropic (via auth token)",
source: "ANTHROPIC_AUTH_TOKEN",
available: true,
});
}
return detected;
}

Expand Down Expand Up @@ -288,6 +325,8 @@ export function providerStatus(root = process.cwd()) {
{ 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) },
{ key: "ANTHROPIC_AUTH_TOKEN", set: Boolean(process.env.ANTHROPIC_AUTH_TOKEN) },
{ key: "ANTHROPIC_MODEL", set: Boolean(process.env.ANTHROPIC_MODEL) },
];

return {
Expand Down
4 changes: 3 additions & 1 deletion src/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { recordRoute } from "./cost_report.js";
import { mergedLessons } from "./ledger_read.js";
import { MODELS } from "./model_tiers.js";
import { preflightRepo, referencedEntities } from "./preflight.js";
import { activeProvider } from "./providers.js";
import { activeProvider, envModelOverride } 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".
Expand Down Expand Up @@ -232,6 +232,7 @@ export function routeTask(
}
}
const recommended = recommend(score, norm);
const modelOvr = envModelOverride();
return {
score,
repoScore,
Expand All @@ -242,6 +243,7 @@ export function routeTask(
: null,
provenance: { path },
...recommended,
modelOverride: modelOvr || undefined,
reasons: [
...new Set([
...(recommended.reasons || []),
Expand Down
36 changes: 35 additions & 1 deletion test/adjudicate.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { adjudicate, asText, asUnit, extractJson, llmEnabled } from "../src/adjudicate.js";
import {
adjudicate,
asText,
asUnit,
buildRunner,
extractJson,
llmEnabled,
} from "../src/adjudicate.js";

const parseScore = (o) => {
const score = asUnit(o.score);
Expand Down Expand Up @@ -107,3 +114,30 @@ test("asUnit clamps to [0,1]; asText trims and caps", () => {
assert.equal(asText(null), "");
assert.equal(asText("x".repeat(500)).length, 200);
});

test("buildRunner: returns a function regardless of transport (HTTP or CLI)", () => {
const runner = buildRunner({ model: "haiku" });
assert.equal(typeof runner, "function");
});

test("buildRunner: FORGE_LLM_HTTP=1 with no provider → runner throws descriptive error", () => {
const saved = {
FORGE_LLM_HTTP: process.env.FORGE_LLM_HTTP,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
LITELLM_API_KEY: process.env.LITELLM_API_KEY,
};
process.env.FORGE_LLM_HTTP = "1";
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.LITELLM_API_KEY;
try {
const runner = buildRunner({ model: "haiku" });
assert.throws(() => runner("test prompt"), /no LLM provider/);
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
});
Loading
Loading