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
14 changes: 14 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,20 @@ runs `bump.mjs auto`: it releases only when a `feat`/`fix`/`perf`/breaking commi
when none exist, and exits `3` (a clean skip, not a failure) otherwise — so releases cut
themselves without a chore/docs merge spamming the registry.

**Custom-gateway model remap (`src/gateway_model_map.js`).** The tier table (`model_tiers.json`)
pins public Anthropic IDs, but a self-hosted LiteLLM/proxy gateway serves its own model names, so a
stock ID sent verbatim 404s. When a non-default gateway base URL is configured, the module fetches
`GET /v1/models` **once per process** (a spawned-node child with the key in env, never argv — the
`llm.js` pattern) and scores each advertised id against every tier's family: the family word
(haiku/sonnet/opus/fable) is a hard gate, the `setOverlap` coefficient of the tier's name tokens
picks the best match, ties break toward the id closest to the canonical name. `resolveModel`
(providers) and `buildRunner` (adjudicate) consult it only when the resolved id is a _stock_ ID —
an explicit `.forge/providers.json` alias or `ANTHROPIC_MODEL` override is never touched — and it
fails safe to the stock ID on no gateway / unreachable `/v1/models` / no family match, so direct
`api.anthropic.com` users are byte-identical. `forge doctor`'s **gateway models** row prints the
resolved `tier→model` mapping for verification. The `MODELS` export shape is unchanged: this is a
resolution-time layer, not a table edit.

**Intent cards (`src/intent.js`).** Prompt → intent by the same exemplar k-NN math as
model routing — a labeled bank (English + Hinglish rows) under overlap similarity with a
confidence gate, NOT a keyword DFA. Note `intentGrams` ≠ `contentGrams`: route.js stops
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Custom-gateway model remap (`src/gateway_model_map.js`). The tier table pins public
Anthropic IDs that a self-hosted LiteLLM/proxy gateway may not serve; when a non-default
gateway base URL is set, Forge fetches `GET /v1/models` once per process and scores each
advertised model against every tier's family (family-word gate + `setOverlap` name-token
score, deterministic tie-break) to remap `haiku/sonnet/opus/fable` onto the gateway's real
IDs. `forge doctor` surfaces the resolved `tier→model` mapping under a **gateway models** row.
Zero breaking change — the `MODELS` export shape is unchanged, it fails safe to the stock ID
on no gateway / unreachable `/v1/models` / no family match, and an explicit
`.forge/providers.json` alias or `ANTHROPIC_MODEL` override always wins. Direct
`api.anthropic.com` sessions never probe and are byte-identical.

## [0.12.4] - 2026-07-11

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ Corporate gateway environments work out of the box: with `ANTHROPIC_BASE_URL` +
`ANTHROPIC_AUTH_TOKEN` set (LiteLLM-style gateways), detection classifies the gateway,
auth uses the token as a Bearer credential, and `ANTHROPIC_MODEL` pins the model.

**Custom gateways that rename models.** The tier table ships public Anthropic IDs
(`claude-haiku-4-5-…`, `claude-sonnet-5`, …), but a self-hosted gateway often serves its
own names (`bedrock-claude-haiku`, `prod-sonnet-5`). When a non-default gateway base URL is
set, Forge asks it once per process (`GET /v1/models`) and scores each advertised model
against every tier's family — the family word (haiku/sonnet/opus/fable) gates the match, the
overlap score picks the best id — then remaps each tier onto a real gateway model. It is a
silent, zero-config fallback: no gateway, an unreachable `/v1/models`, or no family match and
the stock IDs are used unchanged; direct `api.anthropic.com` sessions never probe. An explicit
model in `.forge/providers.json` (or `ANTHROPIC_MODEL`) always wins over the remap. `forge
doctor` prints the resolved `tier→model` mapping under **gateway models** so you can verify it
and pin explicit IDs if a family scored wrong.

### `forge impact <symbol|file>` — what will this edit break?

Reverse-dependency blast radius from the atlas graph. Run `forge atlas build` first.
Expand Down
13 changes: 11 additions & 2 deletions src/adjudicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// - 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, spawnSync } from "node:child_process";
import { gatewayModelId } from "./gateway_model_map.js";
import { buildHttpRunner as httpRunner } from "./llm.js";
import { MODELS } from "./model_tiers.js";
import { envModelOverride } from "./providers.js";
Expand All @@ -31,7 +32,11 @@ function hasClaude() {
if (!_claudeChecked) {
_claudeChecked = true;
try {
const r = spawnSync("which", ["claude"], { encoding: "utf8", timeout: 2000, stdio: "pipe" });
const r = spawnSync("which", ["claude"], {
encoding: "utf8",
timeout: 2000,
stdio: "pipe",
});
_claudeAvail = r.status === 0;
} catch {
_claudeAvail = false;
Expand All @@ -43,7 +48,11 @@ function hasClaude() {
/** 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;
const override = envModelOverride();
const stock = override || MODELS[model]?.id || model;
// A forced override is honored verbatim; otherwise remap the tier's stock id onto a custom
// gateway's real model when one is advertised (no-op for direct Anthropic — see gateway_model_map).
const resolvedModel = override ? stock : gatewayModelId(model, stock);
if (process.env.FORGE_LLM_HTTP === "1" || !hasClaude()) {
return httpRunner({ model: resolvedModel, timeoutMs });
}
Expand Down
38 changes: 38 additions & 0 deletions src/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BRAND } from "./brand.js";
import { summary as cortexSummary } from "./cortex.js";
import { docsCheck } from "./docs_check.js";
import { extractHash, hashContent } from "./emit/_shared.js";
import { gatewayBase, gatewayModelMap } from "./gateway_model_map.js";
import { verify as ledgerVerify, repoLedger } from "./ledger_store.js";
import { PRICING_VERIFIED } from "./model_tiers.js";
import { activeProvider, envModelOverride } from "./providers.js";
Expand Down Expand Up @@ -309,6 +310,42 @@ function checkProvider(out, targetRoot) {
}
}

// Custom-gateway model mapping: stock Anthropic ids can 404 on a self-hosted gateway that
// serves its own names. Surface the tier→gateway-model remap so the user can VERIFY it (and
// pin explicit ids if a family scored wrong). Only speaks for a non-default gateway base URL —
// direct api.anthropic.com sessions never probe, so this stays silent and network-free there.
function checkGateway(out) {
const base = gatewayBase();
if (!base) return; // direct Anthropic or no gateway configured — nothing to remap
let m;
try {
m = gatewayModelMap({ base });
} catch {
m = null;
}
if (!m || m.reachable === false) {
out.push(
warn(
"gateway models",
`${base} — /v1/models unreachable; using stock IDs (may 404 if this gateway renames models)`,
),
);
return;
}
const entries = Object.entries(m.models);
if (!entries.length) {
out.push(
warn(
"gateway models",
`${base} serves ${m.catalog.length} model(s) but none matched a tier family — set explicit IDs via \`${BRAND.cli} config provider add\``,
),
);
return;
}
const summary = entries.map(([tier, v]) => `${tier}→${v.id}`).join(", ");
out.push(ok("gateway models", `${base}: ${summary}`));
}

// Docs↔code drift — a self-check of the forge package's own docs, so it only runs
// when doctor is pointed at the forge repo itself (contributors + CI), never at a
// host project whose README rightly says nothing about forge commands.
Expand Down Expand Up @@ -342,6 +379,7 @@ export function doctor({ targetRoot = process.cwd() } = {}) {
const results = [];
checkNode(results);
checkProvider(results, targetRoot);
checkGateway(results);
checkBrandConsistency(results);
checkLayers(results);
checkGuardsExecutable(results);
Expand Down
195 changes: 195 additions & 0 deletions src/gateway_model_map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// forge gateway model map — remap complexity tiers onto a CUSTOM gateway's real model IDs.
//
// The problem: model_tiers.json pins public Anthropic IDs (claude-haiku-4-5-20251001, …).
// A self-hosted LiteLLM/proxy gateway rarely exposes those exact names — it advertises its
// OWN ids (e.g. "bedrock-claude-haiku", "prod-sonnet", "claude-3-5-sonnet-v2"). Sending a
// stock id straight to such a gateway 404s. So we ask the gateway what it actually serves
// (GET /v1/models, once per process) and SCORE each advertised id against every tier's family
// — the same DATA-is-a-table / DECISION-is-a-formula rule the rest of forge follows: the tier
// families are data, the pick is a graded overlap score (src/math.js setOverlap), inspectable
// and testable.
//
// Contract (zero breaking change):
// - Only engages for a NON-default gateway base URL. Direct api.anthropic.com → no-op, no net.
// - FAIL-SAFE. No gateway, unreachable, unparseable, or no family match → returns the stock
// id unchanged. Callers are byte-identical to before when there is nothing to remap.
// - The MODELS export shape is untouched; nothing here mutates model_tiers.
import { spawnSync } from "node:child_process";
import { setOverlap } from "./math.js";
import { MODELS, TIER_ORDER } from "./model_tiers.js";

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

// GET {base}/v1/models in a spawned node child so this module stays synchronous like every
// other forge faculty (embed.js / llm.js pattern). The auth key travels via the child's env
// (_FORGE_LLM_KEY) — never in argv, never logged. Accepts both OpenAI-shaped ({data:[{id}]})
// and Anthropic-shaped ({data:[{id}]}) catalogs; both key the list under data[].id.
const FETCH_CHILD = `let raw="";process.stdin.on("data",(d)=>{raw+=d;});process.stdin.on("end",async()=>{try{const{url,timeoutMs}=JSON.parse(raw);const key=process.env._FORGE_LLM_KEY||"";const headers={"anthropic-version":"2023-06-01"};if(key.startsWith("Bearer ")){headers.authorization=key;}else if(key){headers["x-api-key"]=key;headers.authorization="Bearer "+key;}const ac=new AbortController();const timer=setTimeout(()=>ac.abort(),timeoutMs||5000);let res;try{res=await fetch(url,{headers,signal:ac.signal});}finally{clearTimeout(timer);}if(!res.ok){process.stderr.write("http "+res.status);process.exit(1);}const data=await res.json();const rows=Array.isArray(data)?data:Array.isArray(data&&data.data)?data.data:[];const ids=rows.map((m)=>(typeof m==="string"?m:m&&m.id)).filter((x)=>typeof x==="string"&&x);process.stdout.write(JSON.stringify(ids));}catch(e){process.stderr.write(String((e&&e.message)||e));process.exit(1);}});`;

// Process-lifetime cache: base URL -> string[] (advertised ids) | null (fetched, none usable).
// "Once per process" is the whole point — the ambient LLM path must not re-probe on every call.
const _catalogCache = new Map();

/** Clear the per-process /v1/models cache (tests only). */
export function _resetGatewayCache() {
_catalogCache.clear();
}

/**
* The active gateway base URL to remap against, or null when there is nothing to remap.
* Mirrors llm.js resolution (LITELLM_BASE_URL wins, then ANTHROPIC_BASE_URL). The default
* Anthropic endpoint returns null so direct-API users never trigger a probe or a remap.
* @returns {string|null}
*/
export function gatewayBase() {
const url = (process.env.LITELLM_BASE_URL || process.env.ANTHROPIC_BASE_URL || "").replace(
/\/+$/,
"",
);
if (!url) return null;
if (url.toLowerCase() === ANTHROPIC_DEFAULT) return null; // direct Anthropic — stock ids are correct
return url;
}

function apiKey() {
return (
process.env.ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_AUTH_TOKEN ||
process.env.LITELLM_API_KEY ||
""
);
}

function spawnFetch(base, timeoutMs) {
const r = spawnSync(process.execPath, ["-e", FETCH_CHILD], {
input: JSON.stringify({ url: `${base}/v1/models`, timeoutMs }),
encoding: "utf8",
timeout: timeoutMs + 1000,
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, _FORGE_LLM_KEY: apiKey() },
stdio: ["pipe", "pipe", "pipe"],
});
if (r.error || r.status !== 0 || !r.stdout) return null;
const ids = JSON.parse(r.stdout);
return Array.isArray(ids) ? ids : null;
}

/**
* Fetch (and cache once per process) the model ids a gateway advertises at /v1/models.
* @param {string} base gateway base URL (no trailing slash)
* @param {{timeoutMs?: number, fetchImpl?: (base:string)=>string[]}} [opts] fetchImpl is injectable for tests
* @returns {string[]|null} advertised ids, or null on any failure
*/
export function fetchModelIds(base, { timeoutMs = 5000, fetchImpl } = {}) {
if (!base) return null;
if (_catalogCache.has(base)) return _catalogCache.get(base);
let ids = null;
try {
ids = fetchImpl ? fetchImpl(base) : spawnFetch(base, timeoutMs);
} catch {
ids = null;
}
const clean = Array.isArray(ids)
? [...new Set(ids.filter((x) => typeof x === "string" && x))]
: null;
const result = clean && clean.length ? clean : null;
_catalogCache.set(base, result);
return result;
}

const tokenize = (s) =>
new Set(
String(s)
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean),
);

/** Reference token set for a tier: the family key plus its marketing-name tokens (e.g. haiku → {haiku,4,5}). */
export function familyTokens(tier) {
return tokenize(`${tier} ${MODELS[tier]?.name ?? ""}`);
}

/**
* Score how well a gateway model id belongs to a tier family, in [0,1].
* The family word itself (haiku/sonnet/opus/fable) is a HARD gate — absent it, the id is not a
* candidate for that tier (score 0), so an unrelated model can never be mis-assigned. Present it,
* the score is the overlap coefficient of the tier's reference tokens with the id's tokens, which
* rewards a version match ("claude-sonnet-5" scores 1.0 for sonnet; "prod-sonnet" scores lower).
* @param {string} modelId
* @param {string} tier
* @returns {number}
*/
export function familyScore(modelId, tier) {
const toks = tokenize(modelId);
if (!toks.has(tier)) return 0; // family word MUST be present
return setOverlap(familyTokens(tier), toks);
}

// Deterministic tie-break among equal-scoring candidates: prefer the id closest to the canonical
// name (fewest tokens — less vendor/deployment noise), then lexicographic for stability.
function tieBreak(a, b) {
const na = tokenize(a).size;
const nb = tokenize(b).size;
if (na !== nb) return na - nb;
return a < b ? -1 : a > b ? 1 : 0;
}

/**
* Pure: given a gateway's advertised ids, pick the best id per tier by family score.
* @param {string[]} ids
* @returns {Record<string,{id:string, score:number}>} only tiers with a family match appear
*/
export function buildGatewayMap(ids = []) {
const list = [...new Set((ids || []).filter((x) => typeof x === "string" && x))];
/** @type {Record<string,{id:string, score:number}>} */
const map = {};
for (const tier of TIER_ORDER) {
let best = null;
for (const id of list) {
const score = familyScore(id, tier);
if (score <= 0) continue;
if (!best || score > best.score || (score === best.score && tieBreak(id, best.id) < 0)) {
best = { id, score };
}
}
if (best) map[tier] = best;
}
return map;
}

/**
* The tier→gateway-model mapping for the active gateway. Fetches /v1/models (cached) and scores.
* @param {{base?: string, fetchImpl?: (base:string)=>string[], timeoutMs?: number}} [opts]
* @returns {{active:boolean, base:(string|null), reachable?:boolean, catalog?:string[], models:Record<string,{id:string,score:number}>}}
*/
export function gatewayModelMap({ base, fetchImpl, timeoutMs } = {}) {
const b = base ?? gatewayBase();
if (!b) return { active: false, base: null, models: {} };
const ids = fetchModelIds(b, { fetchImpl, timeoutMs });
if (!ids) return { active: true, base: b, reachable: false, models: {} };
return {
active: true,
base: b,
reachable: true,
catalog: ids,
models: buildGatewayMap(ids),
};
}

/**
* Resolve a tier to a gateway model id, or return `fallbackId` unchanged (silent fallback).
* This is the one function callers reach for: it never throws and never blocks a direct-API user.
* @param {string} tier
* @param {string} fallbackId the stock id to use when there is nothing to remap
* @param {{base?: string, fetchImpl?: (base:string)=>string[], timeoutMs?: number}} [opts]
* @returns {string}
*/
export function gatewayModelId(tier, fallbackId, opts = {}) {
try {
const m = gatewayModelMap(opts);
return m.models?.[tier]?.id ?? fallbackId;
} catch {
return fallbackId;
}
}
Loading
Loading