SPB Git forge

spb/doc-api

Public
2commits 1branches 0releases
15.7 MBsize
maindefault branch
13 days agolast push
Python 88.3% TypeScript 7.6% Shell 4.1%
16.5 KB · 217 lines typescript
Raw Blame History
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