Skip to content

Commit 742a89b

Browse files
Merge pull request #65 from CodeWithJuber/claude/forge-work-system-setup-p26ka5
feat(providers): remap tiers onto a custom gateway's real model IDs
2 parents 7343864 + 27d2d42 commit 742a89b

9 files changed

Lines changed: 478 additions & 7 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,20 @@ runs `bump.mjs auto`: it releases only when a `feat`/`fix`/`perf`/breaking commi
250250
when none exist, and exits `3` (a clean skip, not a failure) otherwise — so releases cut
251251
themselves without a chore/docs merge spamming the registry.
252252

253+
**Custom-gateway model remap (`src/gateway_model_map.js`).** The tier table (`model_tiers.json`)
254+
pins public Anthropic IDs, but a self-hosted LiteLLM/proxy gateway serves its own model names, so a
255+
stock ID sent verbatim 404s. When a non-default gateway base URL is configured, the module fetches
256+
`GET /v1/models` **once per process** (a spawned-node child with the key in env, never argv — the
257+
`llm.js` pattern) and scores each advertised id against every tier's family: the family word
258+
(haiku/sonnet/opus/fable) is a hard gate, the `setOverlap` coefficient of the tier's name tokens
259+
picks the best match, ties break toward the id closest to the canonical name. `resolveModel`
260+
(providers) and `buildRunner` (adjudicate) consult it only when the resolved id is a _stock_ ID —
261+
an explicit `.forge/providers.json` alias or `ANTHROPIC_MODEL` override is never touched — and it
262+
fails safe to the stock ID on no gateway / unreachable `/v1/models` / no family match, so direct
263+
`api.anthropic.com` users are byte-identical. `forge doctor`'s **gateway models** row prints the
264+
resolved `tier→model` mapping for verification. The `MODELS` export shape is unchanged: this is a
265+
resolution-time layer, not a table edit.
266+
253267
**Intent cards (`src/intent.js`).** Prompt → intent by the same exemplar k-NN math as
254268
model routing — a labeled bank (English + Hinglish rows) under overlap similarity with a
255269
confidence gate, NOT a keyword DFA. Note `intentGrams` ≠ `contentGrams`: route.js stops

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

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

1124
### Fixed

‎docs/GUIDE.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,18 @@ Corporate gateway environments work out of the box: with `ANTHROPIC_BASE_URL` +
226226
`ANTHROPIC_AUTH_TOKEN` set (LiteLLM-style gateways), detection classifies the gateway,
227227
auth uses the token as a Bearer credential, and `ANTHROPIC_MODEL` pins the model.
228228

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

231243
Reverse-dependency blast radius from the atlas graph. Run `forge atlas build` first.

‎src/adjudicate.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// - ZERO-DEP. Access is a `claude -p` CLI shell-out; the runner is injectable so the pure
1212
// prompt/parse/verify logic is fully testable without the CLI or the network.
1313
import { execFileSync, spawnSync } from "node:child_process";
14+
import { gatewayModelId } from "./gateway_model_map.js";
1415
import { buildHttpRunner as httpRunner } from "./llm.js";
1516
import { MODELS } from "./model_tiers.js";
1617
import { envModelOverride } from "./providers.js";
@@ -31,7 +32,11 @@ function hasClaude() {
3132
if (!_claudeChecked) {
3233
_claudeChecked = true;
3334
try {
34-
const r = spawnSync("which", ["claude"], { encoding: "utf8", timeout: 2000, stdio: "pipe" });
35+
const r = spawnSync("which", ["claude"], {
36+
encoding: "utf8",
37+
timeout: 2000,
38+
stdio: "pipe",
39+
});
3540
_claudeAvail = r.status === 0;
3641
} catch {
3742
_claudeAvail = false;
@@ -43,7 +48,11 @@ function hasClaude() {
4348
/** Build an injectable LLM runner. Tries direct HTTP when `claude` CLI is unavailable
4449
* or when FORGE_LLM_HTTP=1. Falls back to `claude -p` otherwise. */
4550
export function buildRunner({ model = "haiku", timeoutMs = 20000 } = {}) {
46-
const resolvedModel = envModelOverride() || MODELS[model]?.id || model;
51+
const override = envModelOverride();
52+
const stock = override || MODELS[model]?.id || model;
53+
// A forced override is honored verbatim; otherwise remap the tier's stock id onto a custom
54+
// gateway's real model when one is advertised (no-op for direct Anthropic — see gateway_model_map).
55+
const resolvedModel = override ? stock : gatewayModelId(model, stock);
4756
if (process.env.FORGE_LLM_HTTP === "1" || !hasClaude()) {
4857
return httpRunner({ model: resolvedModel, timeoutMs });
4958
}

‎src/doctor.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BRAND } from "./brand.js";
88
import { summary as cortexSummary } from "./cortex.js";
99
import { docsCheck } from "./docs_check.js";
1010
import { extractHash, hashContent } from "./emit/_shared.js";
11+
import { gatewayBase, gatewayModelMap } from "./gateway_model_map.js";
1112
import { verify as ledgerVerify, repoLedger } from "./ledger_store.js";
1213
import { PRICING_VERIFIED } from "./model_tiers.js";
1314
import { activeProvider, envModelOverride } from "./providers.js";
@@ -309,6 +310,42 @@ function checkProvider(out, targetRoot) {
309310
}
310311
}
311312

313+
// Custom-gateway model mapping: stock Anthropic ids can 404 on a self-hosted gateway that
314+
// serves its own names. Surface the tier→gateway-model remap so the user can VERIFY it (and
315+
// pin explicit ids if a family scored wrong). Only speaks for a non-default gateway base URL —
316+
// direct api.anthropic.com sessions never probe, so this stays silent and network-free there.
317+
function checkGateway(out) {
318+
const base = gatewayBase();
319+
if (!base) return; // direct Anthropic or no gateway configured — nothing to remap
320+
let m;
321+
try {
322+
m = gatewayModelMap({ base });
323+
} catch {
324+
m = null;
325+
}
326+
if (!m || m.reachable === false) {
327+
out.push(
328+
warn(
329+
"gateway models",
330+
`${base} — /v1/models unreachable; using stock IDs (may 404 if this gateway renames models)`,
331+
),
332+
);
333+
return;
334+
}
335+
const entries = Object.entries(m.models);
336+
if (!entries.length) {
337+
out.push(
338+
warn(
339+
"gateway models",
340+
`${base} serves ${m.catalog.length} model(s) but none matched a tier family — set explicit IDs via \`${BRAND.cli} config provider add\``,
341+
),
342+
);
343+
return;
344+
}
345+
const summary = entries.map(([tier, v]) => `${tier}→${v.id}`).join(", ");
346+
out.push(ok("gateway models", `${base}: ${summary}`));
347+
}
348+
312349
// Docs↔code drift — a self-check of the forge package's own docs, so it only runs
313350
// when doctor is pointed at the forge repo itself (contributors + CI), never at a
314351
// host project whose README rightly says nothing about forge commands.
@@ -342,6 +379,7 @@ export function doctor({ targetRoot = process.cwd() } = {}) {
342379
const results = [];
343380
checkNode(results);
344381
checkProvider(results, targetRoot);
382+
checkGateway(results);
345383
checkBrandConsistency(results);
346384
checkLayers(results);
347385
checkGuardsExecutable(results);

‎src/gateway_model_map.js‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// forge gateway model map — remap complexity tiers onto a CUSTOM gateway's real model IDs.
2+
//
3+
// The problem: model_tiers.json pins public Anthropic IDs (claude-haiku-4-5-20251001, …).
4+
// A self-hosted LiteLLM/proxy gateway rarely exposes those exact names — it advertises its
5+
// OWN ids (e.g. "bedrock-claude-haiku", "prod-sonnet", "claude-3-5-sonnet-v2"). Sending a
6+
// stock id straight to such a gateway 404s. So we ask the gateway what it actually serves
7+
// (GET /v1/models, once per process) and SCORE each advertised id against every tier's family
8+
// — the same DATA-is-a-table / DECISION-is-a-formula rule the rest of forge follows: the tier
9+
// families are data, the pick is a graded overlap score (src/math.js setOverlap), inspectable
10+
// and testable.
11+
//
12+
// Contract (zero breaking change):
13+
// - Only engages for a NON-default gateway base URL. Direct api.anthropic.com → no-op, no net.
14+
// - FAIL-SAFE. No gateway, unreachable, unparseable, or no family match → returns the stock
15+
// id unchanged. Callers are byte-identical to before when there is nothing to remap.
16+
// - The MODELS export shape is untouched; nothing here mutates model_tiers.
17+
import { spawnSync } from "node:child_process";
18+
import { setOverlap } from "./math.js";
19+
import { MODELS, TIER_ORDER } from "./model_tiers.js";
20+
21+
const ANTHROPIC_DEFAULT = "https://api.anthropic.com";
22+
23+
// GET {base}/v1/models in a spawned node child so this module stays synchronous like every
24+
// other forge faculty (embed.js / llm.js pattern). The auth key travels via the child's env
25+
// (_FORGE_LLM_KEY) — never in argv, never logged. Accepts both OpenAI-shaped ({data:[{id}]})
26+
// and Anthropic-shaped ({data:[{id}]}) catalogs; both key the list under data[].id.
27+
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);}});`;
28+
29+
// Process-lifetime cache: base URL -> string[] (advertised ids) | null (fetched, none usable).
30+
// "Once per process" is the whole point — the ambient LLM path must not re-probe on every call.
31+
const _catalogCache = new Map();
32+
33+
/** Clear the per-process /v1/models cache (tests only). */
34+
export function _resetGatewayCache() {
35+
_catalogCache.clear();
36+
}
37+
38+
/**
39+
* The active gateway base URL to remap against, or null when there is nothing to remap.
40+
* Mirrors llm.js resolution (LITELLM_BASE_URL wins, then ANTHROPIC_BASE_URL). The default
41+
* Anthropic endpoint returns null so direct-API users never trigger a probe or a remap.
42+
* @returns {string|null}
43+
*/
44+
export function gatewayBase() {
45+
const url = (process.env.LITELLM_BASE_URL || process.env.ANTHROPIC_BASE_URL || "").replace(
46+
/\/+$/,
47+
"",
48+
);
49+
if (!url) return null;
50+
if (url.toLowerCase() === ANTHROPIC_DEFAULT) return null; // direct Anthropic — stock ids are correct
51+
return url;
52+
}
53+
54+
function apiKey() {
55+
return (
56+
process.env.ANTHROPIC_API_KEY ||
57+
process.env.ANTHROPIC_AUTH_TOKEN ||
58+
process.env.LITELLM_API_KEY ||
59+
""
60+
);
61+
}
62+
63+
function spawnFetch(base, timeoutMs) {
64+
const r = spawnSync(process.execPath, ["-e", FETCH_CHILD], {
65+
input: JSON.stringify({ url: `${base}/v1/models`, timeoutMs }),
66+
encoding: "utf8",
67+
timeout: timeoutMs + 1000,
68+
maxBuffer: 4 * 1024 * 1024,
69+
env: { ...process.env, _FORGE_LLM_KEY: apiKey() },
70+
stdio: ["pipe", "pipe", "pipe"],
71+
});
72+
if (r.error || r.status !== 0 || !r.stdout) return null;
73+
const ids = JSON.parse(r.stdout);
74+
return Array.isArray(ids) ? ids : null;
75+
}
76+
77+
/**
78+
* Fetch (and cache once per process) the model ids a gateway advertises at /v1/models.
79+
* @param {string} base gateway base URL (no trailing slash)
80+
* @param {{timeoutMs?: number, fetchImpl?: (base:string)=>string[]}} [opts] fetchImpl is injectable for tests
81+
* @returns {string[]|null} advertised ids, or null on any failure
82+
*/
83+
export function fetchModelIds(base, { timeoutMs = 5000, fetchImpl } = {}) {
84+
if (!base) return null;
85+
if (_catalogCache.has(base)) return _catalogCache.get(base);
86+
let ids = null;
87+
try {
88+
ids = fetchImpl ? fetchImpl(base) : spawnFetch(base, timeoutMs);
89+
} catch {
90+
ids = null;
91+
}
92+
const clean = Array.isArray(ids)
93+
? [...new Set(ids.filter((x) => typeof x === "string" && x))]
94+
: null;
95+
const result = clean && clean.length ? clean : null;
96+
_catalogCache.set(base, result);
97+
return result;
98+
}
99+
100+
const tokenize = (s) =>
101+
new Set(
102+
String(s)
103+
.toLowerCase()
104+
.split(/[^a-z0-9]+/)
105+
.filter(Boolean),
106+
);
107+
108+
/** Reference token set for a tier: the family key plus its marketing-name tokens (e.g. haiku → {haiku,4,5}). */
109+
export function familyTokens(tier) {
110+
return tokenize(`${tier} ${MODELS[tier]?.name ?? ""}`);
111+
}
112+
113+
/**
114+
* Score how well a gateway model id belongs to a tier family, in [0,1].
115+
* The family word itself (haiku/sonnet/opus/fable) is a HARD gate — absent it, the id is not a
116+
* candidate for that tier (score 0), so an unrelated model can never be mis-assigned. Present it,
117+
* the score is the overlap coefficient of the tier's reference tokens with the id's tokens, which
118+
* rewards a version match ("claude-sonnet-5" scores 1.0 for sonnet; "prod-sonnet" scores lower).
119+
* @param {string} modelId
120+
* @param {string} tier
121+
* @returns {number}
122+
*/
123+
export function familyScore(modelId, tier) {
124+
const toks = tokenize(modelId);
125+
if (!toks.has(tier)) return 0; // family word MUST be present
126+
return setOverlap(familyTokens(tier), toks);
127+
}
128+
129+
// Deterministic tie-break among equal-scoring candidates: prefer the id closest to the canonical
130+
// name (fewest tokens — less vendor/deployment noise), then lexicographic for stability.
131+
function tieBreak(a, b) {
132+
const na = tokenize(a).size;
133+
const nb = tokenize(b).size;
134+
if (na !== nb) return na - nb;
135+
return a < b ? -1 : a > b ? 1 : 0;
136+
}
137+
138+
/**
139+
* Pure: given a gateway's advertised ids, pick the best id per tier by family score.
140+
* @param {string[]} ids
141+
* @returns {Record<string,{id:string, score:number}>} only tiers with a family match appear
142+
*/
143+
export function buildGatewayMap(ids = []) {
144+
const list = [...new Set((ids || []).filter((x) => typeof x === "string" && x))];
145+
/** @type {Record<string,{id:string, score:number}>} */
146+
const map = {};
147+
for (const tier of TIER_ORDER) {
148+
let best = null;
149+
for (const id of list) {
150+
const score = familyScore(id, tier);
151+
if (score <= 0) continue;
152+
if (!best || score > best.score || (score === best.score && tieBreak(id, best.id) < 0)) {
153+
best = { id, score };
154+
}
155+
}
156+
if (best) map[tier] = best;
157+
}
158+
return map;
159+
}
160+
161+
/**
162+
* The tier→gateway-model mapping for the active gateway. Fetches /v1/models (cached) and scores.
163+
* @param {{base?: string, fetchImpl?: (base:string)=>string[], timeoutMs?: number}} [opts]
164+
* @returns {{active:boolean, base:(string|null), reachable?:boolean, catalog?:string[], models:Record<string,{id:string,score:number}>}}
165+
*/
166+
export function gatewayModelMap({ base, fetchImpl, timeoutMs } = {}) {
167+
const b = base ?? gatewayBase();
168+
if (!b) return { active: false, base: null, models: {} };
169+
const ids = fetchModelIds(b, { fetchImpl, timeoutMs });
170+
if (!ids) return { active: true, base: b, reachable: false, models: {} };
171+
return {
172+
active: true,
173+
base: b,
174+
reachable: true,
175+
catalog: ids,
176+
models: buildGatewayMap(ids),
177+
};
178+
}
179+
180+
/**
181+
* Resolve a tier to a gateway model id, or return `fallbackId` unchanged (silent fallback).
182+
* This is the one function callers reach for: it never throws and never blocks a direct-API user.
183+
* @param {string} tier
184+
* @param {string} fallbackId the stock id to use when there is nothing to remap
185+
* @param {{base?: string, fetchImpl?: (base:string)=>string[], timeoutMs?: number}} [opts]
186+
* @returns {string}
187+
*/
188+
export function gatewayModelId(tier, fallbackId, opts = {}) {
189+
try {
190+
const m = gatewayModelMap(opts);
191+
return m.models?.[tier]?.id ?? fallbackId;
192+
} catch {
193+
return fallbackId;
194+
}
195+
}

0 commit comments

Comments
 (0)