Python 88.3%
TypeScript 7.6%
Shell 4.1%
1/**2 * Feature-detection registry over the API Atlas merged files (generated/models.json + generated/tools.json) — 4 providers.3 *4 * STATUS: DOCUMENTED (designed against the `model` / `tool` record schemas in CLAUDE.md and the alias conventions of the four5 * models fragments) · offline self-test against tests/shared/fixtures (2026-09-18, extended to xAI/Gemini on 2026-09-19):6 * node --experimental-strip-types examples/shared/feature-detection/supports.ts --selftest7 *8 * Twin of supports.py — identical tri-state semantics (true | false | "unknown"), resolution rules (aliases/snapshots/redirects,9 * `provider/` and `models/` prefixes, parenthetical notes stripped, dict aliases `{alias, resolves_to_live}`, xAI10 * `kind: "retired_redirect"` targets parsed from `verification.request_note`), capability synonyms, dict-shaped Gemini tools[],11 * camelCase/snake_case tolerant tool matching, modelsWith(), toolsFor(), supportsTool().12 */13import { existsSync, readFileSync } from "node:fs";14import { dirname, join } from "node:path";15import { fileURLToPath } from "node:url";1617const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");18export const DEFAULT_MODELS = join(ROOT, "generated", "models.json");19export const DEFAULT_TOOLS = join(ROOT, "generated", "tools.json");2021export type Tri = boolean | "unknown";22export const UNKNOWN: Tri = "unknown";23export type Rec = Record<string, any>;24export const PROVIDERS = ["openai", "anthropic", "xai", "gemini"];25const SNAPSHOT_SUFFIX = /-(\d{8}|\d{4}-\d{2}-\d{2})$/;26const PAREN_NOTE = /\s*\(.*\)\s*$/;27const REDIRECT_NOTE = /with id ['"]([^'"]+)['"]/;28const RESOURCE_PREFIX = /^(models|tunedModels)\//;2930export const CAPABILITY_SYNONYMS: Record<string, string[]> = {31 structured_outputs: ["structured_output", "structured_outputs_json", "json_schema", "response_format_json_schema"],32 structured_output: ["structured_outputs", "structured_outputs_json"],33 function_calling: ["tool_use", "tools", "function_declarations"],34 web_search: ["tool_web_search", "google_search_grounding", "web_search_tool", "live_search"],35 prompt_caching: ["prompt_caching_automatic", "prompt_caching_explicit", "context_caching_implicit", "context_caching_explicit", "cache_control"],36 reasoning: ["extended_thinking", "thinking", "thinking_extended_manual_budget"], extended_thinking: ["reasoning", "thinking", "thinking_extended_manual_budget"],37 code_execution: ["tool_code_execution", "code_interpreter"], mcp: ["tool_mcp", "mcp_connector", "remote_mcp", "mcp_servers"], computer_use: ["tool_computer_use"],38 streaming: ["stream"], batch: ["batch_api", "batches"], image_in: ["image_input", "vision"], audio_in: ["audio_input"], text_in: ["text_input"],39};4041function records(doc: any, listKeys: string[]): Rec[] {42 if (Array.isArray(doc)) return doc.filter((r) => r && typeof r === "object");43 if (doc && typeof doc === "object") {44 for (const k of listKeys) if (Array.isArray(doc[k])) return doc[k].filter((r: any) => r && typeof r === "object");45 return Object.entries(doc).filter(([, v]) => v && typeof v === "object" && ("provider" in (v as any) || "capabilities" in (v as any) || "type" in (v as any)))46 .map(([k, v]: [string, any]) => ("id" in v || "name" in v ? v : { id: k, ...v }));47 }48 return [];49}50function loadJson(path?: string): any { if (!path || !existsSync(path)) return []; try { return JSON.parse(readFileSync(path, "utf8")); } catch { return []; } }51const tri = (v: any): Tri => (v === true || v === false ? v : UNKNOWN);52const normTool = (s: string) => s.toLowerCase().replace(SNAPSHOT_SUFFIX, "").replace(/[^a-z0-9]/g, "");53function cleanAlias(a: any): string | undefined {54 if (a && typeof a === "object") a = a.alias ?? a.id;55 if (typeof a !== "string") return undefined;56 const s = a.replace(PAREN_NOTE, "").trim().toLowerCase(); return s || undefined;57}58/** Model tools[] entries: strings (OpenAI/Anthropic/xAI) or dicts {type, category, support} (Gemini). */59function toolEntries(rec: Rec): [string, Tri][] {60 const out: [string, Tri][] = [];61 for (const t of rec.tools ?? []) {62 if (typeof t === "string") out.push([t, true]);63 else if (t && typeof t === "object") {64 const typ = t.type ?? t.name; if (typeof typ !== "string") continue;65 let sup: any = t.support ?? true;66 if (typeof sup === "string") { const s = sup.toLowerCase(); sup = s.startsWith("not") || s === "no" || s === "unsupported" ? false : s.includes("support") || s === "yes" || s === "true" ? true : UNKNOWN; }67 out.push([typ, sup === UNKNOWN ? UNKNOWN : tri(sup)]);68 }69 }70 return out;71}7273export class Registry {74 models: Rec[]; tools: Rec[];75 private byId = new Map<string, Rec>(); private alias = new Map<string, string>(); private toolsByKey = new Map<string, Rec>();7677 constructor(models: Rec[] = [], tools: Rec[] = []) { this.models = models; this.tools = tools; this.index(); }78 static load(modelsPath: string | undefined = DEFAULT_MODELS, toolsPath: string | undefined = DEFAULT_TOOLS): Registry { return Registry.fromDocuments(loadJson(modelsPath), loadJson(toolsPath)); }79 static fromDocuments(modelsDoc: any, toolsDoc?: any): Registry { return new Registry(records(modelsDoc, ["records", "models"]), records(toolsDoc, ["records", "tools"])); }8081 /** Explicit redirect/alias target of a record: resolves_to/redirects_to/alias_of fields, dict alias `resolves_to_live`, xAI retired_redirect note. */82 static redirectTarget(m: Rec): string | undefined {83 for (const k of ["resolves_to", "redirects_to", "redirect_to", "alias_of", "resolves_to_live"]) if (typeof m[k] === "string" && m[k].trim()) return cleanAlias(m[k]);84 for (const a of m.aliases ?? []) if (a && typeof a === "object" && cleanAlias(a) === String(m.id ?? "").toLowerCase()) {85 const tgt = a.resolves_to_live ?? a.resolves_to; if (typeof tgt === "string" && !tgt.toLowerCase().startsWith("unknown")) return cleanAlias(tgt);86 }87 if (m.kind === "retired_redirect") { const mm = REDIRECT_NOTE.exec(String(m.verification?.request_note ?? "")); if (mm) return mm[1].toLowerCase(); }88 return undefined;89 }9091 private index() {92 for (const m of this.models) if (typeof m.id === "string") this.byId.set(m.id.toLowerCase(), m);93 for (const m of this.models) {94 const mid = String(m.id ?? "").toLowerCase(); const canon = m.canonical_model;95 if (typeof canon === "string" && canon.toLowerCase() !== mid && this.byId.has(canon.toLowerCase()) && !this.alias.has(mid)) this.alias.set(mid, canon.toLowerCase());96 const tgt = Registry.redirectTarget(m);97 if (tgt && tgt !== mid && this.byId.has(tgt) && !this.alias.has(mid)) this.alias.set(mid, tgt);98 for (const a of [...(m.aliases ?? []), ...(m.snapshots ?? [])]) {99 const name = cleanAlias(a);100 if (a && typeof a === "object") { if (name && name !== mid && !(a.resolves_to_live || a.resolves_to) && !this.alias.has(name)) this.alias.set(name, mid); continue; }101 if (name && name !== mid && !this.alias.has(name)) this.alias.set(name, mid);102 }103 }104 for (const t of this.tools) { const prov = String(t.provider ?? "").toLowerCase(); for (const k of [t.name, t.type]) if (typeof k === "string") { const key = `${prov}|${normTool(k)}`; if (!this.toolsByKey.has(key)) this.toolsByKey.set(key, t); } }105 }106107 resolve(modelId: string): Rec | undefined {108 if (!modelId) return undefined;109 let key = modelId.trim().toLowerCase();110 if (key.includes("/") && PROVIDERS.includes(key.split("/")[0])) key = key.split("/").slice(1).join("/");111 key = key.replace(RESOURCE_PREFIX, "");112 const seen = new Set<string>();113 while (!this.byId.has(key) && this.alias.has(key) && !seen.has(key)) { seen.add(key); key = this.alias.get(key)!; }114 let rec = this.byId.get(key);115 if (!rec) { const stripped = key.replace(SNAPSHOT_SUFFIX, ""); return stripped !== key ? this.resolve(stripped) : undefined; }116 for (let hops = 0; hops < 8; hops++) { // pointer-only records (empty capabilities / alias / retired_redirect) defer to their target117 const id = String(rec.id ?? "").toLowerCase(); const canon = rec.canonical_model;118 const tgt = typeof canon === "string" && canon.toLowerCase() !== id ? canon.toLowerCase() : this.alias.get(id);119 const pointer = !rec.capabilities || Object.keys(rec.capabilities).length === 0 || ["alias", "retired_redirect"].includes(rec.kind);120 if (tgt && tgt !== id && this.byId.has(tgt) && pointer) { rec = this.byId.get(tgt)!; continue; }121 break;122 }123 return rec;124 }125 canonicalId(modelId: string): string | undefined { return this.resolve(modelId)?.id; }126127 supports(modelId: string, capability: string): Tri {128 const rec = this.resolve(modelId); if (!rec) return UNKNOWN;129 const caps = rec.capabilities; if (!caps || typeof caps !== "object") return UNKNOWN;130 if (capability in caps) return tri(caps[capability]); // explicit key wins, even "unknown"131 for (const syn of CAPABILITY_SYNONYMS[capability] ?? []) if (syn in caps) { const v = tri(caps[syn]); if (v !== UNKNOWN) return v; }132 if (capability.startsWith("tool_")) return this.supportsTool(modelId, capability.slice(5)); // full tri-state inference133 return this.supportsTool(modelId, capability) === true ? true : UNKNOWN; // bare names: positive match only, never a silent false134 }135136 supportsTool(modelId: string, tool: string): Tri {137 const rec = this.resolve(modelId); if (!rec) return UNKNOWN;138 if (!Array.isArray(rec.tools)) return UNKNOWN;139 const want = normTool(tool); const entries = toolEntries(rec);140 for (const [typ, sup] of entries) { const tl = normTool(typ); if (tl === want || tl.startsWith(want + "20") || tl.startsWith(want + "v")) return sup; }141 const trec = this.toolsByKey.get(`${String(rec.provider ?? "").toLowerCase()}|${want}`);142 if (trec && Array.isArray(trec.compatible_models)) {143 const ids = new Set(trec.compatible_models.map((x: any) => String(x).toLowerCase()));144 const names = new Set([String(rec.id ?? "").toLowerCase(), ...(rec.aliases ?? []).map(cleanAlias).filter(Boolean)]);145 for (const n of names) if (ids.has(n as string)) return true; // positive-only signal (tool records may be partial)146 }147 return entries.length ? false : UNKNOWN;148 }149150 toolsFor(modelId: string): string[] {151 const rec = this.resolve(modelId); if (!rec) return [];152 const out: string[] = toolEntries(rec).filter(([, sup]) => sup !== false).map(([typ]) => typ);153 const prov = String(rec.provider ?? "").toLowerCase(); const mid = String(rec.id ?? "").toLowerCase();154 for (const t of this.tools) if (String(t.provider ?? "").toLowerCase() === prov && (t.compatible_models ?? []).map((x: any) => String(x).toLowerCase()).includes(mid)) {155 const typ = t.type ?? t.name; if (typeof typ === "string" && !out.some((o) => normTool(o) === normTool(typ))) out.push(typ);156 }157 return out;158 }159160 capabilities(modelId: string): Record<string, Tri> {161 const caps = this.resolve(modelId)?.capabilities;162 return caps && typeof caps === "object" ? Object.fromEntries(Object.entries(caps).map(([k, v]) => [k, tri(v)])) : {};163 }164165 modelsWith(capabilities: string[], opts: { provider?: string; includeUnknown?: boolean; statusesExcluded?: string[] } = {}): string[] {166 const excluded = new Set(opts.statusesExcluded ?? ["RETIRED", "DEPRECATED"]);167 const out: string[] = [];168 for (const m of this.models) {169 // pointer records only: OpenAI record_kind snapshot, xAI/Gemini kind alias|retired_redirect (Anthropic's kind "snapshot" = a real dated model)170 if (typeof m.id !== "string" || m.record_kind === "snapshot" || ["alias", "retired_redirect"].includes(m.kind)) continue;171 if (opts.provider && String(m.provider ?? "").toLowerCase() !== opts.provider.toLowerCase()) continue;172 if ((m.status ?? []).some((s: string) => excluded.has(s))) continue;173 let ok = true;174 for (const c of capabilities) { const v = this.supports(m.id, c); if (v === false || (v === UNKNOWN && !opts.includeUnknown)) { ok = false; break; } }175 if (ok) out.push(m.id);176 }177 return out;178 }179180 toolRecord(provider: string, tool: string): Rec | undefined { return this.toolsByKey.get(`${provider.toLowerCase()}|${normTool(tool)}`); }181 compare(a: string, b: string): Record<string, [Tri, Tri]> {182 const ca = this.capabilities(a), cb = this.capabilities(b);183 return Object.fromEntries([...new Set([...Object.keys(ca), ...Object.keys(cb)])].sort().map((k) => [k, [ca[k] ?? UNKNOWN, cb[k] ?? UNKNOWN]]));184 }185 providers(): string[] { return [...new Set(this.models.map((m) => String(m.provider ?? "").toLowerCase()).filter(Boolean))].sort(); }186}187188if (process.argv.includes("--selftest")) {189 const FIX = join(ROOT, "tests", "shared", "fixtures");190 const r = Registry.load(join(FIX, "models.json"), join(FIX, "tools.json"));191 const assert = (c: unknown, m: string) => { if (!c) { console.error("FAIL", m); process.exit(1); } };192 assert(r.supports("gpt-5.4-nano", "function_calling") === true, "true");193 assert(r.supports("gpt-5.4-nano", "fine_tuning") === false, "false");194 assert(r.supports("gpt-5.4-nano", "extended_thinking") === "unknown" && r.supports("gpt-5.4-nano", "nope") === "unknown" && r.supports("ada", "streaming") === "unknown" && r.supports("zzz", "x") === "unknown", "unknowns");195 assert(r.canonicalId("claude-haiku-4-5") === "claude-haiku-4-5-20251001" && r.canonicalId("gpt-5.4-nano-2026-03-17") === "gpt-5.4-nano" && r.canonicalId("openai/GPT-5.4-nano-latest") === "gpt-5.4-nano", "aliases");196 assert(r.canonicalId("claude-haiku-4-5-20251001-20260101") === "claude-haiku-4-5-20251001", "date suffix");197 assert(JSON.stringify(r.modelsWith(["function_calling", "structured_outputs"])) === JSON.stringify(["gpt-5.4-nano", "claude-haiku-4-5-20251001", "grok-4.3", "gemini-3.8-flash", "gemini-3.5-flash-lite"]), `modelsWith ${r.modelsWith(["function_calling", "structured_outputs"])}`);198 assert(JSON.stringify(r.modelsWith(["extended_thinking"], { includeUnknown: true, provider: "openai" })) === JSON.stringify(["gpt-5.4-nano"]), "includeUnknown");199 assert(r.supportsTool("claude-haiku-4-5", "web_fetch") === true && r.supportsTool("gpt-5.4-nano", "computer_use_preview") === false && r.supportsTool("ada", "web_search") === "unknown", "supportsTool");200 assert(r.supports("claude-opus-4-1", "tool_web_search") === true && r.supportsTool("claude-opus-4-1", "web_fetch") === false, "tool inference");201 assert(r.toolsFor("claude-haiku-4-5").includes("mcp_toolset"), "toolsFor");202 assert(Registry.load("/nonexistent.json", undefined).supports("x", "y") === "unknown", "missing files");203 assert(r.compare("gpt-5.4-nano", "claude-haiku-4-5").tool_computer_use.join() === "false,true", "compare");204 // xAI: redirect aliases, parenthetical alias notes, `unknown` server tool flags205 assert(r.canonicalId("grok-3") === "grok-4.3" && r.canonicalId("xai/grok-4.3-latest") === "grok-4.3" && r.canonicalId("grok-voice-latest") === "grok-voice-think-fast-2.0", `xai aliases ${r.canonicalId("grok-3")}`);206 assert(r.supports("grok-3", "structured_outputs") === true && r.supports("grok-4.3", "web_search") === "unknown" && r.supportsTool("grok-4.3", "web_search") === true && r.supports("grok-4.3", "prompt_caching") === true, "xai caps");207 assert(!r.modelsWith(["function_calling"]).includes("grok-3"), "retired redirect skipped in modelsWith");208 // Gemini: models/ prefix, -latest dict alias, dict tools[], camelCase/snake_case, synonyms209 assert(r.canonicalId("models/gemini-flash-latest") === "gemini-3.8-flash" && r.canonicalId("gemini/gemini-flash-latest") === "gemini-3.8-flash", `gemini latest ${r.canonicalId("models/gemini-flash-latest")}`);210 assert(r.supports("gemini-flash-latest", "structured_outputs") === true && r.supports("gemini-3.8-flash", "web_search") === true && r.supports("gemini-3.8-flash", "prompt_caching") === true, "gemini synonyms");211 assert(r.supportsTool("gemini-3.8-flash", "googleSearch") === true && r.supportsTool("gemini-3.8-flash", "google_search") === true && r.supportsTool("gemini-3.8-flash", "file_search") === false && r.supportsTool("gemini-3.8-flash", "computer_use") === true, "gemini dict tools");212 assert(r.toolsFor("gemini-3.8-flash").includes("google_search") && !r.toolsFor("gemini-3.8-flash").includes("file_search") && !r.toolsFor("gemini-3.8-flash").includes("googleSearch"), "gemini toolsFor dedupe");213 assert(JSON.stringify(r.modelsWith(["web_search", "structured_outputs", "prompt_caching"])) === JSON.stringify(["gpt-5.4-nano", "claude-haiku-4-5-20251001", "gemini-3.8-flash", "gemini-3.5-flash-lite"]), `cross-provider modelsWith ${r.modelsWith(["web_search", "structured_outputs", "prompt_caching"])}`);214 assert(JSON.stringify(r.providers()) === JSON.stringify(["anthropic", "gemini", "openai", "xai"]), "providers");215 console.log("supports.ts selftest: all assertions passed");216}217