mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Replaces lookup_pricing(raw_model_id) + normalize_for_pricing + 17 prefix rules
with an exact (billing-authority domain, billable-model-string) lookup. Billing
authority is a distinct namespace from the runtime transport Provider enum.
Match is exact: no case normalization, no prefix matching, no inference.
Manifest (scripts/model-capabilities.json):
- Removed pricing_rules (17 prefix-match rules), pricing_exact_records (3 records)
- Added flat pricing_records list of 20 exact records, each with authority + model +
usd_per_mtok (input/output/cache_read/cache_write) + _source provenance
- Authorities: api.anthropic.com, api.openai.com — registered bare-host tokens per
D2'/NIP-AM; not transport Provider enum values
- Semantics: current list prices (ccusage-aligned); cache_write scoped to ephemeral;
other classes unknown
Generator (scripts/generate-model-capabilities.mjs):
- Removed all old pricing machinery (pricingNormalize, normalizeVersionSeparators,
pricingRules, pricingExactRecords, resolvePricing, all TS rule-resolver emission)
- Strict pricing-record validation: registered authority (closed set: api.anthropic.com,
api.openai.com, openrouter.ai); nonempty verbatim model; no control/NUL/quote/backslash
in key fields; finite nonnegative input/output; both cache_read and cache_write required
(absent member is hard error; use null for unpublished); _source provenance required;
duplicate detection on exact emitted identities
- byAuthority grouping uses exact record values — no toLowerCase() anywhere in
pricing generation path
- REGISTERED_AUTHORITIES set; hasUnsafeKeyChars() guard
- TS PRICING_TABLE: key is ${authority}\0${model} (exact strings, no transforms)
- lookupModelPricing(authority, model) performs exact Map lookup (no normalization)
- modelPricing.ts is standalone (no import from modelCapabilities)
Generated artifacts:
- generated_model_capabilities.rs: lookup_pricing(authority: &str, model: &str)
performs exact match — no to_ascii_lowercase() calls, no fallback
- modelPricing.ts: exact PRICING_TABLE + lookupModelPricing(authority, model)
CI (.github/workflows/ci.yml):
- Replaced two-step "regenerate + git diff" check with a single
node scripts/generate-model-capabilities.mjs --check step, which validates
all three generated artifacts (Rust capabilities, TS capabilities, TS pricing)
and exits 1 if any are stale. Self-maintains if a fourth artifact is added.
Tests (generated_model_capabilities_tests.rs, 23 pricing tests):
- Anthropic models with correct rates (claude-fable-5, claude-opus-5, claude-sonnet-5,
claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5)
- OpenAI models incl. gpt-5.6-luna different tier, gpt-5.5 null cache_write, gpt-5-pro
null cache fields
- Null guards: unknown authority, custom base URL, unknown model, empty authority,
empty model, both empty, wrong authority for known model
- Exact-match guards: uppercase authority -> None, mixed-case authority -> None,
mixed-case model -> None (no case folding)
- Databricks guards: workspace URL authority -> None, 'databricks' authority -> None
Manifest validator tests (scripts/test-manifest-validator.mjs, 45 tests total):
- 21 new pricing-record mutation tests covering: missing authority, unregistered/path-
bearing/scheme authority, empty model, unsafe chars (double-quote, backslash, NUL,
control), missing output, negative rates, invalid cache rate, deleted cache_read,
deleted cache_write, missing/empty provenance, duplicate records, uppercase authority
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1746 lines
68 KiB
JavaScript
1746 lines
68 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Model-capability manifest generator.
|
||
*
|
||
* Reads `scripts/model-capabilities.json` and emits:
|
||
* - `crates/buzz-agent/src/generated_model_capabilities.rs`
|
||
* - `desktop/src/features/agents/ui/modelCapabilities.ts`
|
||
*
|
||
* The generator performs three ordered resolution steps (resolver contract, plan v4):
|
||
* 1. Provider-qualified raw exact lookup — key is (provider, raw_model_id), matched
|
||
* BEFORE any prefix stripping or normalization.
|
||
* 2. Provider-scoped ordered family rules — match kinds: exact | prefix | gpt5-token |
|
||
* gpt5-base | segment | segment-prefix, ordered by match_priority (desc).
|
||
* 3. Per-axis provider fallback — blank vs concrete-unknown states.
|
||
*
|
||
* Completeness validator: every emitted CapabilityResult must have every axis populated.
|
||
* A generation-time failure on any unresolvable axis is a hard error.
|
||
*
|
||
* Usage:
|
||
* node scripts/generate-model-capabilities.mjs [--check]
|
||
*
|
||
* --check: verify that already-generated files match what the generator would produce
|
||
* (used by CI regenerate-then-diff job). Exits 1 if any file differs.
|
||
*/
|
||
|
||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||
import { join, dirname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { spawnSync } from "node:child_process";
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const repoRoot = join(__dirname, "..");
|
||
const CHECK_MODE = process.argv.includes("--check");
|
||
const CHECK_GOOSE_MODE = process.argv.includes("--check-goose");
|
||
|
||
// Run content through rustfmt (stdin → stdout) if available; fall back to raw.
|
||
// Uses hermit-pinned rustfmt from bin/ when present (same binary as pre-commit hook).
|
||
function rustfmt(content) {
|
||
const hermitBin = join(repoRoot, "bin", "rustfmt");
|
||
const hermitExists = existsSync(hermitBin);
|
||
const rustfmtBin = hermitExists ? hermitBin : "rustfmt";
|
||
const result = spawnSync(rustfmtBin, ["--edition", "2021", "--emit", "stdout"], {
|
||
input: content,
|
||
encoding: "utf8",
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
});
|
||
if (result.status === 0 && result.stdout) return result.stdout;
|
||
if (hermitExists && result.status !== 0) {
|
||
// Hermit-pinned binary is present but failed — fail closed so CI catches drift.
|
||
const stderr = result.stderr ?? "";
|
||
throw new Error(`rustfmt (hermit-pinned) exited ${result.status}: ${stderr.trim()}`);
|
||
}
|
||
// rustfmt not available (PATH fallback also absent) — warn and return raw.
|
||
// CI's regen-diff gate will catch any drift.
|
||
process.stderr.write("warning: rustfmt not available; raw Rust output may not be formatter-stable\n");
|
||
return content;
|
||
}
|
||
|
||
// Support --manifest-path and --output-dir flags for testing
|
||
const manifestPathOverride = (() => {
|
||
const idx = process.argv.indexOf("--manifest-path");
|
||
return idx !== -1 ? process.argv[idx + 1] : null;
|
||
})();
|
||
const outputDirOverride = (() => {
|
||
const idx = process.argv.indexOf("--output-dir");
|
||
return idx !== -1 ? process.argv[idx + 1] : null;
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Load manifest
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const manifestPath = manifestPathOverride ?? join(repoRoot, "scripts", "model-capabilities.json");
|
||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||
|
||
// Validate registry_labels — must be an array of {id, label} objects with unique IDs.
|
||
// JSON.parse() silently overwrites duplicate object keys, so an array is required for
|
||
// structural duplicate detection.
|
||
const registryLabelsArr = manifest.registry_labels ?? [];
|
||
if (!Array.isArray(registryLabelsArr)) {
|
||
throw new Error("registry_labels: must be an array of {id, label} objects");
|
||
}
|
||
for (const entry of registryLabelsArr) {
|
||
if (!entry.id || typeof entry.id !== "string" || entry.id.trim() === "") {
|
||
throw new Error(`registry_labels: entry missing nonempty string "id": ${JSON.stringify(entry)}`);
|
||
}
|
||
if (!entry.label || typeof entry.label !== "string" || entry.label.trim() === "") {
|
||
throw new Error(`registry_labels: entry id="${entry.id}" missing nonempty string "label"`);
|
||
}
|
||
// Safe for code interpolation: reject double-quote, backslash, and control chars.
|
||
// (These characters would break emitted Rust/TS string literals.)
|
||
// Checked via requireSafeString() below (shared validator defined after knownModels block).
|
||
const unsafeId = entry.id.includes('"') || entry.id.includes("\\") ||
|
||
Array.from(entry.id).some((c) => c.charCodeAt(0) < 32);
|
||
if (unsafeId) {
|
||
throw new Error(`registry_labels: entry id="${entry.id}" contains unsafe characters`);
|
||
}
|
||
const unsafeLabel = entry.label.includes('"') || entry.label.includes("\\") ||
|
||
Array.from(entry.label).some((c) => c.charCodeAt(0) < 32);
|
||
if (unsafeLabel) {
|
||
throw new Error(`registry_labels: entry id="${entry.id}" label contains unsafe characters`);
|
||
}
|
||
}
|
||
const registryLabelIds = registryLabelsArr.map((e) => e.id);
|
||
const registryLabelSet = new Set(registryLabelIds);
|
||
if (registryLabelSet.size !== registryLabelIds.length) {
|
||
const dups = registryLabelIds.filter((id, i) => registryLabelIds.indexOf(id) !== i);
|
||
throw new Error(`registry_labels: duplicate endpoint IDs detected: ${dups.join(", ")}`);
|
||
}
|
||
|
||
// Validate databricks_v2_known_models uniqueness
|
||
const knownModels = manifest.databricks_v2_known_models ?? [];
|
||
const knownModelsSet = new Set(knownModels);
|
||
if (knownModelsSet.size !== knownModels.length) {
|
||
throw new Error(`databricks_v2_known_models: duplicate IDs detected: ${
|
||
knownModels.filter((id, i) => knownModels.indexOf(id) !== i).join(", ")
|
||
}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared string-safety validator
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// All manifest strings that end up inside generated Rust or TypeScript string literals
|
||
// must not contain double-quote, backslash, or control characters — any of these would
|
||
// break the emitted source or allow injection. One shared check prevents the same class
|
||
// of defect from appearing at scattered emission sites.
|
||
//
|
||
// Usage: requireSafeString(value, "context.path") — throws on violation.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function requireSafeString(value, context) {
|
||
if (typeof value !== "string" || value.length === 0) {
|
||
throw new Error(`${context}: must be a nonempty string, got ${JSON.stringify(value)}`);
|
||
}
|
||
const hasUnsafe = value.includes('"') || value.includes("\\") ||
|
||
Array.from(value).some((c) => c.charCodeAt(0) < 32);
|
||
if (hasUnsafe) {
|
||
throw new Error(`${context}: contains unsafe characters (double-quote, backslash, or control chars): ${JSON.stringify(value)}`);
|
||
}
|
||
}
|
||
|
||
// Validate all manifest strings that end up in generated Rust/TS string literals.
|
||
// family_tokens → Rust FAMILY_TOKENS array and TS FAMILY_TOKENS constant
|
||
for (const tok of manifest.family_tokens ?? []) {
|
||
requireSafeString(tok, "family_tokens[]");
|
||
}
|
||
|
||
// databricks_v2_known_models IDs → Rust/TS DATABRICKS_V2_KNOWN_MODELS array
|
||
for (const id of knownModels) {
|
||
requireSafeString(id, "databricks_v2_known_models[]");
|
||
}
|
||
|
||
// exact_records → Rust lookup_exact() match arms and TS EXACT_RECORDS map keys
|
||
for (const rec of manifest.exact_records ?? []) {
|
||
requireSafeString(rec.provider ?? "", `exact_records[${rec.raw_model_id}].provider`);
|
||
requireSafeString(rec.raw_model_id ?? "", `exact_records[${rec.raw_model_id}].raw_model_id`);
|
||
}
|
||
|
||
// family_rules → Rust/TS lookup_by_family_rules() match-expression strings
|
||
for (const rule of manifest.family_rules ?? []) {
|
||
requireSafeString(rule.id ?? "", `family_rules[${rule.id}].id`);
|
||
requireSafeString(rule.match_value ?? "", `family_rules[${rule.id}].match_value`);
|
||
for (const alias of rule.match_aliases ?? []) {
|
||
requireSafeString(alias, `family_rules[${rule.id}].match_aliases[]`);
|
||
}
|
||
for (const provider of rule.providers ?? []) {
|
||
requireSafeString(provider, `family_rules[${rule.id}].providers[]`);
|
||
}
|
||
// registry_label flows into Rust Some("...") and TS "..." string literals
|
||
if (rule.registry_label != null) {
|
||
requireSafeString(rule.registry_label, `family_rules[${rule.id}].registry_label`);
|
||
}
|
||
}
|
||
|
||
// exact_records: registry_label values flow into Rust rustString() and TS emitter
|
||
for (const rec of manifest.exact_records ?? []) {
|
||
if (rec.registry_label != null) {
|
||
requireSafeString(rec.registry_label, `exact_records[${rec.raw_model_id}].registry_label`);
|
||
}
|
||
}
|
||
|
||
// provider_fallbacks: object keys are interpolated as Rust match arms and TS object keys
|
||
for (const providerKey of Object.keys(manifest.provider_fallbacks ?? {})) {
|
||
if (providerKey !== "_default") {
|
||
requireSafeString(providerKey, `provider_fallbacks key`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// --check-goose: optional drift check against pinned goose upstream
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Reads the goose source file at the pinned revision and verifies that
|
||
// manifest.databricks_v2_known_models exactly matches the IDs declared there.
|
||
//
|
||
// Usage (non-CI, opt-in):
|
||
// node scripts/generate-model-capabilities.mjs --check-goose
|
||
//
|
||
// Pin metadata lives in manifest._sources.goose_known_models:
|
||
// "goose revision <SHA> (<path>:<lines>) — <ids>"
|
||
//
|
||
// The check fetches the raw file from github.com/block/goose via the GitHub
|
||
// contents API and parses DATABRICKS_V2_KNOWN_MODELS from it.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
if (CHECK_GOOSE_MODE) {
|
||
// Parse pin metadata from _sources
|
||
const pin = manifest._sources?.goose_known_models;
|
||
if (!pin) {
|
||
console.error("--check-goose: manifest._sources.goose_known_models is missing.");
|
||
process.exit(1);
|
||
}
|
||
const revMatch = pin.match(/revision\s+([0-9a-f]{7,40})/i);
|
||
const pathMatch = pin.match(/\(([^)]+\.rs):/);
|
||
if (!revMatch || !pathMatch) {
|
||
console.error(`--check-goose: could not parse revision/path from pin metadata: ${pin}`);
|
||
process.exit(1);
|
||
}
|
||
const pinnedRev = revMatch[1];
|
||
const goosePath = pathMatch[1]; // e.g. "crates/goose-providers/src/databricks_v2.rs"
|
||
|
||
// Fetch file content from GitHub API (no token required for public repos)
|
||
const url = `https://raw.githubusercontent.com/block/goose/${pinnedRev}/${goosePath}`;
|
||
let fileContent;
|
||
try {
|
||
const result = spawnSync("curl", ["-fsS", "--max-time", "15", url], {
|
||
encoding: "utf8",
|
||
maxBuffer: 2 * 1024 * 1024,
|
||
});
|
||
if (result.status !== 0) {
|
||
console.error(`--check-goose: curl failed (exit ${result.status}): ${result.stderr?.trim()}`);
|
||
console.error(` URL: ${url}`);
|
||
process.exit(1);
|
||
}
|
||
fileContent = result.stdout;
|
||
} catch (e) {
|
||
console.error(`--check-goose: fetch failed: ${e.message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Extract DATABRICKS_V2_KNOWN_MODELS from Rust source: parse &[&str] literal
|
||
// Pattern: pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &[ "id1", "id2", ... ];
|
||
const constMatch = fileContent.match(
|
||
/DATABRICKS_V2_KNOWN_MODELS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/,
|
||
);
|
||
if (!constMatch) {
|
||
console.error(`--check-goose: could not find DATABRICKS_V2_KNOWN_MODELS in goose source at ${pinnedRev}:${goosePath}`);
|
||
process.exit(1);
|
||
}
|
||
const gooseIds = [...constMatch[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
|
||
const manifestIds = [...(manifest.databricks_v2_known_models ?? [])].sort();
|
||
|
||
const onlyInGoose = gooseIds.filter((id) => !manifestIds.includes(id));
|
||
const onlyInManifest = manifestIds.filter((id) => !gooseIds.includes(id));
|
||
|
||
if (onlyInGoose.length === 0 && onlyInManifest.length === 0) {
|
||
console.log(`--check-goose: OK — manifest matches goose@${pinnedRev} (${gooseIds.length} IDs)`);
|
||
} else {
|
||
if (onlyInGoose.length > 0) {
|
||
console.error(`--check-goose: DRIFT — IDs in goose@${pinnedRev} not in manifest: ${onlyInGoose.join(", ")}`);
|
||
}
|
||
if (onlyInManifest.length > 0) {
|
||
console.error(`--check-goose: DRIFT — IDs in manifest not in goose@${pinnedRev}: ${onlyInManifest.join(", ")}`);
|
||
}
|
||
console.error(`Pin: ${pin}`);
|
||
process.exit(1);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validation helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const VALID_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||
const VALID_THINKING_MODES = ["manual-budget", "adaptive", "omit-fields", "none", "not-applicable"];
|
||
const VALID_DBV2_ROUTES = [
|
||
"openai-responses",
|
||
"anthropic-messages",
|
||
"mlflow-chat",
|
||
"route-unknown",
|
||
"not-applicable",
|
||
];
|
||
const VALID_NORM_POLICIES = ["none", "openai-standard", "openai-clamp-max-to-xhigh"];
|
||
const VALID_MATCH_KINDS = [
|
||
"exact",
|
||
"prefix",
|
||
"gpt5-token",
|
||
"gpt5-base",
|
||
"segment",
|
||
"segment-prefix",
|
||
];
|
||
|
||
function assertEnum(value, valid, label) {
|
||
if (!valid.includes(value)) {
|
||
throw new Error(`${label}: invalid value "${value}" (must be one of: ${valid.join(", ")})`);
|
||
}
|
||
}
|
||
|
||
function assertNonEmpty(arr, label) {
|
||
if (!Array.isArray(arr) || arr.length === 0) {
|
||
throw new Error(`${label}: must be a non-empty array`);
|
||
}
|
||
}
|
||
|
||
function validateFallbackRecord(rec, label) {
|
||
assertEnum(rec.databricks_v2_wire_route, VALID_DBV2_ROUTES, `${label}.databricks_v2_wire_route`);
|
||
assertEnum(rec.thinking_mode, VALID_THINKING_MODES, `${label}.thinking_mode`);
|
||
assertNonEmpty(rec.supported_efforts, `${label}.supported_efforts`);
|
||
for (const e of rec.supported_efforts) {
|
||
assertEnum(e, VALID_EFFORTS, `${label}.supported_efforts[]`);
|
||
}
|
||
if (rec.default_effort !== null) {
|
||
assertEnum(rec.default_effort, VALID_EFFORTS, `${label}.default_effort`);
|
||
if (!rec.supported_efforts.includes(rec.default_effort)) {
|
||
throw new Error(
|
||
`${label}: default_effort "${rec.default_effort}" not in supported_efforts [${rec.supported_efforts.join(", ")}]`,
|
||
);
|
||
}
|
||
}
|
||
assertEnum(rec.normalization_policy, VALID_NORM_POLICIES, `${label}.normalization_policy`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validate manifest structure
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Validate family_rules
|
||
const seenRuleIds = new Set();
|
||
for (const rule of manifest.family_rules) {
|
||
if (!rule.id) throw new Error("family_rule missing id");
|
||
if (seenRuleIds.has(rule.id)) throw new Error(`duplicate family_rule id: ${rule.id}`);
|
||
seenRuleIds.add(rule.id);
|
||
assertEnum(rule.match_kind, VALID_MATCH_KINDS, `rule ${rule.id} match_kind`);
|
||
assertEnum(rule.thinking_mode, VALID_THINKING_MODES, `rule ${rule.id} thinking_mode`);
|
||
assertNonEmpty(rule.supported_efforts, `rule ${rule.id} supported_efforts`);
|
||
for (const e of rule.supported_efforts) {
|
||
assertEnum(e, VALID_EFFORTS, `rule ${rule.id} supported_efforts[]`);
|
||
}
|
||
if (rule.default_effort !== null) {
|
||
assertEnum(rule.default_effort, VALID_EFFORTS, `rule ${rule.id} default_effort`);
|
||
if (!rule.supported_efforts.includes(rule.default_effort)) {
|
||
throw new Error(
|
||
`rule ${rule.id}: default_effort "${rule.default_effort}" not in supported_efforts`,
|
||
);
|
||
}
|
||
}
|
||
assertEnum(
|
||
rule.databricks_v2_wire_route,
|
||
VALID_DBV2_ROUTES,
|
||
`rule ${rule.id} databricks_v2_wire_route`,
|
||
);
|
||
assertEnum(
|
||
rule.normalization_policy,
|
||
VALID_NORM_POLICIES,
|
||
`rule ${rule.id} normalization_policy`,
|
||
);
|
||
}
|
||
|
||
// Validate provider_fallbacks
|
||
for (const [provider, fb] of Object.entries(manifest.provider_fallbacks)) {
|
||
for (const state of ["blank", "concrete_unknown"]) {
|
||
if (!fb[state])
|
||
throw new Error(`provider_fallbacks.${provider} missing "${state}" record`);
|
||
validateFallbackRecord(fb[state], `provider_fallbacks.${provider}.${state}`);
|
||
}
|
||
}
|
||
|
||
// Validate exact_records — check for duplicate (provider, raw_model_id) keys
|
||
const seenExactKeys = new Set();
|
||
for (const rec of manifest.exact_records ?? []) {
|
||
if (!rec.provider || !rec.raw_model_id)
|
||
throw new Error("exact_record missing provider or raw_model_id");
|
||
const key = `${rec.provider}::${rec.raw_model_id}`;
|
||
if (seenExactKeys.has(key)) throw new Error(`duplicate exact_record key: ${key}`);
|
||
seenExactKeys.add(key);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Resolution engine (mirrors plan resolver contract)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Strip catalog prefix to get the normalized alias for family-rule matching.
|
||
* Finds the first occurrence of a known family token and returns from there.
|
||
* e.g. "goose-claude-fable-5" → "claude-fable-5"
|
||
* "databricks-gpt-5.5" → "gpt-5.5"
|
||
* "claude-opus-4-7" → "claude-opus-4-7" (no prefix)
|
||
*/
|
||
function stripCatalogPrefix(model) {
|
||
const lower = model.toLowerCase();
|
||
let firstIdx = Infinity;
|
||
for (const tok of manifest.family_tokens) {
|
||
const idx = lower.indexOf(tok);
|
||
if (idx !== -1 && idx < firstIdx) firstIdx = idx;
|
||
}
|
||
return firstIdx === Infinity ? model : model.slice(firstIdx);
|
||
}
|
||
|
||
/**
|
||
* gpt5-token match: model contains token at a word boundary (end-of-string or "-").
|
||
* Does NOT match if followed by a digit or letter.
|
||
*/
|
||
function gpt5TokenMatches(model, token) {
|
||
const lower = model.toLowerCase();
|
||
let start = 0;
|
||
while (true) {
|
||
const idx = lower.indexOf(token.toLowerCase(), start);
|
||
if (idx === -1) return false;
|
||
const afterIdx = idx + token.length;
|
||
const afterChar = afterIdx < lower.length ? lower[afterIdx] : "";
|
||
if (afterChar === "" || afterChar === "-") return true;
|
||
start = afterIdx;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* gpt5-base match: like gpt5-token but also rejects short -<1-3 digit> suffixes.
|
||
*/
|
||
function gpt5BaseMatches(model, token) {
|
||
const lower = model.toLowerCase();
|
||
let start = 0;
|
||
while (true) {
|
||
const idx = lower.indexOf(token.toLowerCase(), start);
|
||
if (idx === -1) return false;
|
||
const afterIdx = idx + token.length;
|
||
const suffix = lower.slice(afterIdx);
|
||
if (suffix === "") return true;
|
||
if (!suffix.startsWith("-")) {
|
||
start = afterIdx;
|
||
continue;
|
||
}
|
||
const dashRest = suffix.slice(1);
|
||
if (/^\d{1,3}(?:[^a-z\d]|$)/i.test(dashRest)) {
|
||
start = afterIdx;
|
||
continue;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Test if a family rule matches the given (normalized) model string for a provider.
|
||
*/
|
||
function ruleMatchesModel(rule, normalizedModel, provider) {
|
||
if (!rule.providers.includes(provider)) return false;
|
||
const lower = normalizedModel.toLowerCase();
|
||
const allTokens = [rule.match_value, ...(rule.match_aliases ?? [])];
|
||
switch (rule.match_kind) {
|
||
case "exact":
|
||
return allTokens.some((t) => lower === t.toLowerCase());
|
||
case "prefix":
|
||
return allTokens.some((t) => lower.startsWith(t.toLowerCase()));
|
||
case "gpt5-token":
|
||
return allTokens.some((t) => gpt5TokenMatches(lower, t));
|
||
case "gpt5-base":
|
||
return allTokens.some((t) => gpt5BaseMatches(lower, t));
|
||
case "segment": {
|
||
const segs = lower.split(/[^a-z0-9]+/);
|
||
return allTokens.some((t) => segs.includes(t.toLowerCase()));
|
||
}
|
||
case "segment-prefix": {
|
||
const segs = lower.split(/[^a-z0-9]+/);
|
||
return allTokens.some((t) => segs.some((s) => s.startsWith(t.toLowerCase())));
|
||
}
|
||
default:
|
||
throw new Error(`unknown match_kind: ${rule.match_kind}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve (provider, rawModelId) → CapabilityResult.
|
||
* Three steps: raw exact lookup → family rules → provider fallback.
|
||
*/
|
||
function resolve(provider, rawModelId) {
|
||
const isBlank = !rawModelId || rawModelId.trim() === "";
|
||
|
||
// Step 1: provider-qualified raw exact lookup (BEFORE any prefix stripping)
|
||
const exactRecord = (manifest.exact_records ?? []).find(
|
||
(r) => r.provider === provider && r.raw_model_id === rawModelId,
|
||
);
|
||
|
||
if (exactRecord) {
|
||
// Materialize the full record by evaluating family rules for any axis not overridden.
|
||
const normalizedAlias = stripCatalogPrefix(rawModelId);
|
||
const familyResult = resolveFamilyRules(provider, normalizedAlias, rawModelId);
|
||
const base = familyResult ?? getProviderFallback(provider, isBlank);
|
||
// Determine which axes came from family rule vs exact record for per-axis provenance.
|
||
const effortsFromExact = exactRecord.supported_efforts_override !== undefined;
|
||
const routeFromExact = exactRecord.databricks_v2_wire_route !== undefined;
|
||
const modeFromExact = exactRecord.thinking_mode !== undefined;
|
||
const normFromExact = exactRecord.normalization_policy !== undefined;
|
||
const effortDefaultFromExact = exactRecord.default_effort !== undefined;
|
||
const labelFromExact = exactRecord.registry_label !== undefined;
|
||
const familyProv = familyResult
|
||
? { rule_id: familyResult._provenance.rule_id, rule_priority: familyResult._provenance.rule_priority }
|
||
: null;
|
||
return {
|
||
registry_label: exactRecord.registry_label ?? null,
|
||
thinking_mode: exactRecord.thinking_mode ?? base.thinking_mode,
|
||
supported_efforts: exactRecord.supported_efforts_override ?? base.supported_efforts,
|
||
default_effort: exactRecord.default_effort !== undefined ? exactRecord.default_effort : base.default_effort,
|
||
databricks_v2_wire_route: exactRecord.databricks_v2_wire_route ?? base.databricks_v2_wire_route,
|
||
normalization_policy: exactRecord.normalization_policy ?? base.normalization_policy,
|
||
_provenance: {
|
||
source: "exact",
|
||
exact_key: `${provider}::${rawModelId}`,
|
||
registry_label: labelFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "absent"),
|
||
supported_efforts: effortsFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "fallback"),
|
||
databricks_v2_wire_route: routeFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "fallback"),
|
||
thinking_mode: modeFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "fallback"),
|
||
normalization_policy: normFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "fallback"),
|
||
default_effort: effortDefaultFromExact ? "exact_record" : (familyProv ? `family:${familyProv.rule_id}@${familyProv.rule_priority}` : "fallback"),
|
||
},
|
||
};
|
||
}
|
||
|
||
// Step 2: provider-scoped family rules on normalized alias
|
||
const normalizedAlias = stripCatalogPrefix(rawModelId ?? "");
|
||
const familyResult = resolveFamilyRules(provider, normalizedAlias, rawModelId);
|
||
if (familyResult) return familyResult;
|
||
|
||
// Step 3: provider fallback
|
||
const fallback = getProviderFallback(provider, isBlank);
|
||
return { ...fallback, registry_label: null };
|
||
}
|
||
|
||
function resolveFamilyRules(provider, normalizedAlias, rawModelId) {
|
||
if (!normalizedAlias) return null;
|
||
|
||
// Sort rules by match_priority descending (higher priority wins)
|
||
const sorted = [...manifest.family_rules].sort((a, b) => b.match_priority - a.match_priority);
|
||
|
||
for (const rule of sorted) {
|
||
if (ruleMatchesModel(rule, normalizedAlias, provider)) {
|
||
// databricks_v2_wire_route is only meaningful for databricks_v2; all other providers get not-applicable
|
||
const wireRoute = provider === "databricks_v2"
|
||
? rule.databricks_v2_wire_route
|
||
: "not-applicable";
|
||
return {
|
||
registry_label: rule.registry_label ?? null,
|
||
thinking_mode: rule.thinking_mode,
|
||
supported_efforts: rule.supported_efforts,
|
||
default_effort: rule.default_effort,
|
||
databricks_v2_wire_route: wireRoute,
|
||
normalization_policy: rule.normalization_policy,
|
||
_provenance: {
|
||
source: "family",
|
||
rule_id: rule.id,
|
||
rule_priority: rule.match_priority,
|
||
normalized_alias: normalizedAlias,
|
||
raw_model_id: rawModelId,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getProviderFallback(provider, isBlank) {
|
||
const fb =
|
||
manifest.provider_fallbacks[provider] ?? manifest.provider_fallbacks["_default"];
|
||
const state = isBlank ? "blank" : "concrete_unknown";
|
||
const rec = fb[state];
|
||
return {
|
||
...rec,
|
||
_provenance: { source: "fallback", provider, state },
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rust code generation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function rustString(s) {
|
||
if (s === null || s === undefined) return "None";
|
||
return `Some("${s}")`;
|
||
}
|
||
|
||
function rustEffortList(efforts) {
|
||
const mapped = efforts.map((e) => `ThinkingEffort::${capitalize(e)}`);
|
||
return `&[${mapped.join(", ")}]`;
|
||
}
|
||
|
||
function rustEffortOption(e) {
|
||
if (e === null || e === undefined) return "None";
|
||
return `Some(ThinkingEffort::${capitalize(e)})`;
|
||
}
|
||
|
||
function capitalize(s) {
|
||
if (s === "xhigh") return "XHigh";
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
function rustDbv2Route(route) {
|
||
const map = {
|
||
"openai-responses": "DatabricksV2Route::OpenAiResponses",
|
||
"anthropic-messages": "DatabricksV2Route::AnthropicMessages",
|
||
"mlflow-chat": "DatabricksV2Route::MlflowChatCompletions",
|
||
"route-unknown": "DatabricksV2Route::RouteUnknown",
|
||
"not-applicable": "DatabricksV2Route::NotApplicable",
|
||
};
|
||
if (!map[route]) throw new Error(`unknown dbv2 route: ${route}`);
|
||
return map[route];
|
||
}
|
||
|
||
function rustNormPolicy(policy) {
|
||
const map = {
|
||
none: "NormalizationPolicy::None",
|
||
"openai-standard": "NormalizationPolicy::OpenAiStandard",
|
||
"openai-clamp-max-to-xhigh": "NormalizationPolicy::OpenAiClampMaxToXHigh",
|
||
};
|
||
if (!map[policy]) throw new Error(`unknown norm policy: ${policy}`);
|
||
return map[policy];
|
||
}
|
||
|
||
function rustThinkingMode(mode) {
|
||
const map = {
|
||
"manual-budget": "ThinkingMode::ManualBudget",
|
||
adaptive: "ThinkingMode::Adaptive",
|
||
"omit-fields": "ThinkingMode::OmitFields",
|
||
none: "ThinkingMode::None",
|
||
"not-applicable": "ThinkingMode::NotApplicable",
|
||
};
|
||
if (!map[mode]) throw new Error(`unknown thinking mode: ${mode}`);
|
||
return map[mode];
|
||
}
|
||
|
||
function emitRustCapabilityResult(r, indent = " ") {
|
||
const i = indent;
|
||
const lines = [
|
||
`${i}CapabilityResult {`,
|
||
`${i} registry_label: ${rustString(r.registry_label)},`,
|
||
`${i} thinking_mode: ${rustThinkingMode(r.thinking_mode)},`,
|
||
`${i} supported_efforts: Cow::Borrowed(${rustEffortList(r.supported_efforts)}),`,
|
||
`${i} default_effort: ${rustEffortOption(r.default_effort)},`,
|
||
`${i} databricks_v2_wire_route: ${rustDbv2Route(r.databricks_v2_wire_route)},`,
|
||
`${i} normalization_policy: ${rustNormPolicy(r.normalization_policy)},`,
|
||
`${i}}`,
|
||
];
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// Build the exact_records map entries for Rust
|
||
const exactMapEntries = [];
|
||
for (const rec of manifest.exact_records ?? []) {
|
||
const result = resolve(rec.provider, rec.raw_model_id);
|
||
// Strip provenance from emitted result
|
||
const clean = { ...result };
|
||
delete clean._provenance;
|
||
const provNote = result._provenance
|
||
? (() => {
|
||
const p = result._provenance;
|
||
if (p.source !== "exact") return `// source: ${p.source}`;
|
||
return [
|
||
`// provenance: exact(${p.exact_key})`,
|
||
`// registry_label: ${p.registry_label}`,
|
||
`// supported_efforts: ${p.supported_efforts}`,
|
||
`// databricks_v2_wire_route: ${p.databricks_v2_wire_route}`,
|
||
`// thinking_mode: ${p.thinking_mode}`,
|
||
`// normalization_policy: ${p.normalization_policy}`,
|
||
`// default_effort: ${p.default_effort}`,
|
||
].join("\n ");
|
||
})()
|
||
: "";
|
||
exactMapEntries.push({ rec, clean, provNote });
|
||
}
|
||
|
||
// Build provider fallback static arrays
|
||
function emitRustFallbackFn(provider, state, rec) {
|
||
const clean = { ...rec };
|
||
delete clean._provenance;
|
||
const stateId = state === "blank" ? "Blank" : "ConcreteUnknown";
|
||
return emitRustCapabilityResult(clean, " ");
|
||
}
|
||
|
||
// Collect all providers for fallback fns
|
||
const providerFallbackKeys = Object.keys(manifest.provider_fallbacks).filter(
|
||
(k) => k !== "_default",
|
||
);
|
||
|
||
const rustContent = `// @generated — do not edit by hand.
|
||
// Regenerate with: node scripts/generate-model-capabilities.mjs
|
||
// Source: scripts/model-capabilities.json
|
||
|
||
//! Generated model-capability lookup tables.
|
||
//!
|
||
//! Resolution is a total function \`resolve(provider, raw_model_id) → CapabilityResult\`.
|
||
//! Three ordered steps (plan v4 resolver contract):
|
||
//! 1. Provider-qualified raw exact lookup (before any prefix stripping).
|
||
//! 2. Provider-scoped ordered family rules on the normalized alias.
|
||
//! 3. Per-axis provider fallback (blank vs concrete-unknown).
|
||
//!
|
||
//! The manifest (scripts/model-capabilities.json) is the single source of truth.
|
||
//! This file is regenerated by scripts/generate-model-capabilities.mjs.
|
||
//! CI verifies that generated files match the manifest (regenerate-then-diff).
|
||
//!
|
||
//! Boundaries this file does NOT own (plan v4 §Boundaries):
|
||
//! - Transport/endpoint selection for pure OpenAI, legacy Databricks, OpenRouter:
|
||
//! OpenAiApi / openai_request() remain authoritative.
|
||
//! - Final display labels: resolveModelLabel() three-tier precedence is authoritative.
|
||
//! \`registry_label\` here feeds only the static registry tier.
|
||
//! - llm.rs replacement scope: ONLY \`databricks_v2_route_for_model\`.
|
||
|
||
use crate::config::ThinkingEffort;
|
||
use std::borrow::Cow;
|
||
|
||
/// Which Databricks v2 gateway wire path to use for a model.
|
||
/// Scoped to DBv2 only — other providers use \`NotApplicable\`.
|
||
/// Transport for pure OpenAI, legacy Databricks, and OpenRouter is selected
|
||
/// by OpenAiApi / openai_request() at runtime, not by this manifest.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum DatabricksV2Route {
|
||
/// /ai-gateway/openai/v1/responses
|
||
OpenAiResponses,
|
||
/// /ai-gateway/anthropic/v1/messages
|
||
AnthropicMessages,
|
||
/// /ai-gateway/mlflow/v1/chat/completions
|
||
MlflowChatCompletions,
|
||
/// DBv2 blank model — route not yet determinable.
|
||
RouteUnknown,
|
||
/// Not a DBv2 provider — transport is selected by OpenAiApi/openai_request().
|
||
NotApplicable,
|
||
}
|
||
|
||
/// Anthropic thinking API shape for this model.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ThinkingMode {
|
||
/// thinking:{type:"enabled", budget_tokens} — claude-3*, claude-opus-4-5
|
||
ManualBudget,
|
||
/// thinking:{type:"adaptive"} + output_config:{effort} — opus-4-6+, sonnet-4-6+, etc.
|
||
Adaptive,
|
||
/// Unknown Anthropic model — omit thinking fields rather than guess request shape.
|
||
OmitFields,
|
||
/// Non-Anthropic-routed model — thinking fields are not applicable.
|
||
None,
|
||
/// Provider does not use Anthropic thinking API at all.
|
||
NotApplicable,
|
||
}
|
||
|
||
/// How to normalize effort values before sending to the provider.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum NormalizationPolicy {
|
||
/// Pass effort through unchanged.
|
||
None,
|
||
/// Apply per-family effort table; none↔minimal peer fallback + upward tie preference.
|
||
OpenAiStandard,
|
||
/// Unknown OpenAI model: clamp max → xhigh, pass all others unchanged.
|
||
OpenAiClampMaxToXHigh,
|
||
}
|
||
|
||
/// Complete resolved capability record for a (provider, raw_model_id) pair.
|
||
/// Every axis is populated — runtime consumers do not compose fields.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct CapabilityResult {
|
||
/// Optional static display label. Feeds resolveModelLabel()'s registry tier only.
|
||
/// The dynamic three-tier precedence (discovered_name > registry_label > raw_id)
|
||
/// lives in formatAgentModelLabel / resolveModelLabel — NOT in this struct.
|
||
pub registry_label: Option<&'static str>,
|
||
/// Anthropic thinking API shape for this model.
|
||
pub thinking_mode: ThinkingMode,
|
||
/// Valid effort values for the model's effort dropdown (UI).
|
||
pub supported_efforts: Cow<'static, [ThinkingEffort]>,
|
||
/// Semantic default, or None when "Inherit" is the natural default (manual-budget Anthropic).
|
||
pub default_effort: Option<ThinkingEffort>,
|
||
/// DBv2 wire route; NotApplicable for non-DBv2 providers.
|
||
pub databricks_v2_wire_route: DatabricksV2Route,
|
||
/// Effort normalization policy before sending to provider.
|
||
pub normalization_policy: NormalizationPolicy,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Exact records — provider-qualified (provider, raw_model_id), pre-prefix-stripping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Returns the exact capability record for a provider-qualified raw model ID,
|
||
/// if one exists in the manifest. This is checked BEFORE any prefix stripping.
|
||
pub fn lookup_exact(provider: &str, raw_model_id: &str) -> Option<CapabilityResult> {
|
||
match (provider, raw_model_id) {
|
||
${exactMapEntries
|
||
.map(({ rec, clean, provNote }) => {
|
||
return ` ("${rec.provider}", "${rec.raw_model_id}") => {
|
||
${provNote}
|
||
Some(
|
||
${emitRustCapabilityResult(clean, " ")}
|
||
)
|
||
}`;
|
||
})
|
||
.join("\n")}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Family rule resolution — normalized alias after prefix stripping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Strip any catalog-naming prefix to get the normalized model alias for family matching.
|
||
/// Finds the first occurrence of a known family token (claude-, gpt-) and returns from there.
|
||
///
|
||
/// Examples:
|
||
/// "goose-claude-fable-5" → "claude-fable-5"
|
||
/// "databricks-gpt-5.5" → "gpt-5.5"
|
||
/// "team-x-claude-opus-4-7" → "claude-opus-4-7"
|
||
/// "claude-opus-4-7" → "claude-opus-4-7" (no prefix)
|
||
/// "llama-3" → "llama-3" (no family token)
|
||
pub fn strip_catalog_prefix(model: &str) -> &str {
|
||
const FAMILY_TOKENS: &[&str] = &[${manifest.family_tokens.map((t) => `"${t}"`).join(", ")}];
|
||
let lower = model.to_ascii_lowercase();
|
||
let first_idx = FAMILY_TOKENS
|
||
.iter()
|
||
.filter_map(|tok| lower.find(tok))
|
||
.min();
|
||
match first_idx {
|
||
Some(idx) => &model[idx..],
|
||
None => model,
|
||
}
|
||
}
|
||
|
||
${emitRustFamilyResolverFn()}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Provider fallbacks — blank vs concrete-unknown, per provider
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Returns the fallback capability record for the given provider and model state.
|
||
/// \`is_blank\` is true when the model string is empty/whitespace; false when it is
|
||
/// a nonblank but unmatched concrete ID.
|
||
pub fn provider_fallback(provider: &str, is_blank: bool) -> CapabilityResult {
|
||
match (provider, is_blank) {
|
||
${providerFallbackKeys
|
||
.map((provider) => {
|
||
const fb = manifest.provider_fallbacks[provider];
|
||
const blankClean = { ...fb.blank };
|
||
delete blankClean._provenance;
|
||
const concClean = { ...fb.concrete_unknown };
|
||
delete concClean._provenance;
|
||
return ` ("${provider}", true) => {
|
||
${emitRustCapabilityResult({ ...blankClean, registry_label: null }, " ")}
|
||
}
|
||
("${provider}", false) => {
|
||
${emitRustCapabilityResult({ ...concClean, registry_label: null }, " ")}
|
||
}`;
|
||
})
|
||
.join("\n")}
|
||
// Default fallback for unknown/empty providers
|
||
(_, true) => {
|
||
${emitRustCapabilityResult(
|
||
{
|
||
...manifest.provider_fallbacks["_default"].blank,
|
||
registry_label: null,
|
||
},
|
||
" ",
|
||
)}
|
||
}
|
||
(_, false) => {
|
||
${emitRustCapabilityResult(
|
||
{
|
||
...manifest.provider_fallbacks["_default"].concrete_unknown,
|
||
registry_label: null,
|
||
},
|
||
" ",
|
||
)}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Top-level resolve — total function, always returns a complete result
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Resolve (provider, raw_model_id) → CapabilityResult.
|
||
///
|
||
/// This is the single entry point. Result is complete — every axis is populated.
|
||
/// Consumers never compose fields from multiple tiers at runtime.
|
||
///
|
||
/// Resolution order (plan v4 resolver contract):
|
||
/// 1. provider-qualified raw exact lookup (before any prefix stripping)
|
||
/// 2. provider-scoped family rules on normalized alias
|
||
/// 3. per-axis provider fallback (blank vs concrete-unknown)
|
||
pub fn resolve_model_capabilities(provider: &str, raw_model_id: &str) -> CapabilityResult {
|
||
// Step 1: raw exact lookup
|
||
if let Some(exact) = lookup_exact(provider, raw_model_id) {
|
||
return exact;
|
||
}
|
||
|
||
// Step 2: family rules on normalized alias
|
||
let normalized = strip_catalog_prefix(raw_model_id);
|
||
if let Some(family) = lookup_by_family_rules(provider, normalized) {
|
||
return family;
|
||
}
|
||
|
||
// Step 3: provider fallback
|
||
let is_blank = raw_model_id.trim().is_empty();
|
||
let mut fallback = provider_fallback(provider, is_blank);
|
||
fallback.registry_label = None;
|
||
fallback
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Generated constants (fold-in from #3603 branch)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Valid thinking-effort values accepted by buzz-agent.
|
||
/// Mirrors parse_thinking_effort in config.rs.
|
||
pub const THINKING_EFFORT_VALUES: &[&str] = &[${VALID_EFFORTS.map((e) => `"${e}"`).join(", ")}];
|
||
|
||
/// Databricks v2 known model IDs. Mirrors goose DATABRICKS_V2_KNOWN_MODELS.
|
||
/// Single source of truth inside buzz; generated from manifest databricks_v2_known_models section.
|
||
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &[
|
||
${manifest.databricks_v2_known_models
|
||
.map((id) => ` "${id}",`)
|
||
.join("\n")}
|
||
];
|
||
|
||
/// Databricks endpoint-ID to display-name registry. Generated from manifest registry_labels section.
|
||
/// Feeds the static registry tier of resolveModelLabel(). Final display label is determined
|
||
/// by the three-tier precedence in resolveModelLabel() (discovered_name > registry_label > raw_id).
|
||
pub const DATABRICKS_MODEL_NAMES: &[(&str, &str)] = &[
|
||
${registryLabelsArr
|
||
.map(({ id, label }) => ` ("${id}", "${label}"),`)
|
||
.join("\n")}
|
||
];
|
||
`;
|
||
|
||
function emitRustFamilyResolverFn() {
|
||
// Generate match arms for each family rule, sorted by priority desc
|
||
const sorted = [...manifest.family_rules].sort((a, b) => b.match_priority - a.match_priority);
|
||
|
||
// Group by provider for the generated fn
|
||
const allProviders = [
|
||
...new Set(sorted.flatMap((r) => r.providers)),
|
||
];
|
||
|
||
// We emit a single fn that takes (provider: &str, normalized: &str)
|
||
// and returns Option<CapabilityResult>. We use if-else chains.
|
||
|
||
const arms = [];
|
||
for (const rule of sorted) {
|
||
for (const provider of rule.providers) {
|
||
const clean = { ...rule };
|
||
delete clean._provenance;
|
||
// databricks_v2_wire_route is only meaningful for databricks_v2; all other providers get not-applicable
|
||
const wireRoute = provider === "databricks_v2"
|
||
? rule.databricks_v2_wire_route
|
||
: "not-applicable";
|
||
const matchExpr = buildRustMatchExpr(rule, provider);
|
||
arms.push(
|
||
` // rule: ${rule.id}, provider: ${provider}, priority: ${rule.match_priority}\n if provider == "${provider}" && (${matchExpr}) {\n return Some(\n${emitRustCapabilityResult(
|
||
{
|
||
registry_label: rule.registry_label ?? null,
|
||
thinking_mode: rule.thinking_mode,
|
||
supported_efforts: rule.supported_efforts,
|
||
default_effort: rule.default_effort,
|
||
databricks_v2_wire_route: wireRoute,
|
||
normalization_policy: rule.normalization_policy,
|
||
},
|
||
" ",
|
||
)}\n );\n }`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return `/// Resolve capability by family rules on the normalized (prefix-stripped) alias.
|
||
/// Returns None if no rule matches (caller falls through to provider_fallback).
|
||
///
|
||
/// Rules are ordered by match_priority descending.
|
||
/// Generated from manifest family_rules.
|
||
pub fn lookup_by_family_rules(provider: &str, normalized: &str) -> Option<CapabilityResult> {
|
||
let lower = normalized.to_ascii_lowercase();
|
||
let lower = lower.as_str();
|
||
#[allow(clippy::nonminimal_bool)]
|
||
${arms.join("\n")}
|
||
None
|
||
}`;
|
||
}
|
||
|
||
function buildRustMatchExpr(rule, provider) {
|
||
const allTokens = [rule.match_value, ...(rule.match_aliases ?? [])];
|
||
switch (rule.match_kind) {
|
||
case "exact":
|
||
return allTokens.map((t) => `lower == "${t.toLowerCase()}"`).join(" || ");
|
||
case "prefix":
|
||
return allTokens.map((t) => `lower.starts_with("${t.toLowerCase()}")`).join(" || ");
|
||
case "gpt5-token":
|
||
return allTokens
|
||
.map((t) => `gpt5_token_matches_rs(lower, "${t.toLowerCase()}")`)
|
||
.join(" || ");
|
||
case "gpt5-base":
|
||
return allTokens.map((t) => `gpt5_base_matches_rs(lower, "${t.toLowerCase()}")`).join(" || ");
|
||
case "segment": {
|
||
// Use a contains approach: split on non-alphanumeric, check any segment equals token
|
||
return allTokens
|
||
.map((t) => `lower.split(|c: char| !c.is_ascii_alphanumeric()).any(|s| s == "${t.toLowerCase()}")`)
|
||
.join(" || ");
|
||
}
|
||
case "segment-prefix": {
|
||
return allTokens
|
||
.map(
|
||
(t) =>
|
||
`lower.split(|c: char| !c.is_ascii_alphanumeric()).any(|s| s.starts_with("${t.toLowerCase()}"))`,
|
||
)
|
||
.join(" || ");
|
||
}
|
||
default:
|
||
throw new Error(`unknown match_kind: ${rule.match_kind}`);
|
||
}
|
||
}
|
||
|
||
// Append gpt5 helper fns that the generated code calls
|
||
const rustGpt5Helpers = `
|
||
// ---------------------------------------------------------------------------
|
||
// gpt5 boundary-aware token helpers (used by generated family resolver)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Returns true if \`model\` contains \`token\` at a word boundary (end-of-string or "-").
|
||
/// Does not match if followed immediately by a digit or letter.
|
||
fn gpt5_token_matches_rs(model: &str, token: &str) -> bool {
|
||
let lower = model;
|
||
let tok_lower = token;
|
||
let mut start = 0;
|
||
loop {
|
||
match lower[start..].find(tok_lower) {
|
||
None => return false,
|
||
Some(rel_idx) => {
|
||
let abs_idx = start + rel_idx;
|
||
let after_idx = abs_idx + tok_lower.len();
|
||
let after_char = lower[after_idx..].chars().next();
|
||
match after_char {
|
||
None | Some('-') => return true,
|
||
_ => start = after_idx,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Like gpt5_token_matches_rs but also rejects short -<1-3 digit> suffixes.
|
||
fn gpt5_base_matches_rs(model: &str, token: &str) -> bool {
|
||
let lower = model;
|
||
let tok_lower = token;
|
||
let mut start = 0;
|
||
loop {
|
||
match lower[start..].find(tok_lower) {
|
||
None => return false,
|
||
Some(rel_idx) => {
|
||
let abs_idx = start + rel_idx;
|
||
let after_idx = abs_idx + tok_lower.len();
|
||
let suffix = &lower[after_idx..];
|
||
if suffix.is_empty() {
|
||
return true;
|
||
}
|
||
if !suffix.starts_with('-') {
|
||
start = after_idx;
|
||
continue;
|
||
}
|
||
let dash_rest = &suffix[1..];
|
||
// Reject -<1-3 digits> that look like version numbers.
|
||
let is_short_version = dash_rest
|
||
.chars()
|
||
.take(4)
|
||
.enumerate()
|
||
.all(|(i, c)| {
|
||
if i < 3 { c.is_ascii_digit() }
|
||
else { !c.is_ascii_alphanumeric() }
|
||
})
|
||
&& dash_rest.chars().next().is_some_and(|c| c.is_ascii_digit());
|
||
if is_short_version {
|
||
start = after_idx;
|
||
continue;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
#[path = "generated_model_capabilities_tests.rs"]
|
||
mod tests;
|
||
`;
|
||
|
||
const finalRustContent = rustfmt(rustContent + rustGpt5Helpers);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TypeScript code generation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function tsEffortList(efforts) {
|
||
return `[${efforts.map((e) => `"${e}"`).join(", ")}] as const`;
|
||
}
|
||
|
||
function tsEffortOrNull(e) {
|
||
if (e === null || e === undefined) return "null";
|
||
return `"${e}"`;
|
||
}
|
||
|
||
function tsDbv2Route(route) {
|
||
return `"${route}"`;
|
||
}
|
||
|
||
function tsThinkingMode(mode) {
|
||
return `"${mode}"`;
|
||
}
|
||
|
||
function tsNormPolicy(policy) {
|
||
return `"${policy}"`;
|
||
}
|
||
|
||
function emitTsCapabilityResult(r, indent = " ") {
|
||
const i = indent;
|
||
return [
|
||
`{`,
|
||
`${i} registryLabel: ${r.registry_label === null ? "null" : `"${r.registry_label}"`},`,
|
||
`${i} thinkingMode: ${tsThinkingMode(r.thinking_mode)},`,
|
||
`${i} supportedEfforts: ${tsEffortList(r.supported_efforts)},`,
|
||
`${i} defaultEffort: ${tsEffortOrNull(r.default_effort)},`,
|
||
`${i} databricksV2WireRoute: ${tsDbv2Route(r.databricks_v2_wire_route)},`,
|
||
`${i} normalizationPolicy: ${tsNormPolicy(r.normalization_policy)},`,
|
||
`${i}}`,
|
||
].join(`\n${i}`);
|
||
}
|
||
|
||
// Build TS exact records map
|
||
const tsExactEntries = [];
|
||
for (const rec of manifest.exact_records ?? []) {
|
||
const result = resolve(rec.provider, rec.raw_model_id);
|
||
const clean = { ...result };
|
||
delete clean._provenance;
|
||
tsExactEntries.push({ rec, clean });
|
||
}
|
||
|
||
// Build TS family rule resolution (inlined ordered if-chain for readability)
|
||
function emitTsFamilyResolver() {
|
||
const sorted = [...manifest.family_rules].sort((a, b) => b.match_priority - a.match_priority);
|
||
const arms = [];
|
||
for (const rule of sorted) {
|
||
for (const provider of rule.providers) {
|
||
// databricks_v2_wire_route is only meaningful for databricks_v2; all other providers get not-applicable
|
||
const wireRoute = provider === "databricks_v2"
|
||
? rule.databricks_v2_wire_route
|
||
: "not-applicable";
|
||
const clean = {
|
||
registry_label: rule.registry_label ?? null,
|
||
thinking_mode: rule.thinking_mode,
|
||
supported_efforts: rule.supported_efforts,
|
||
default_effort: rule.default_effort,
|
||
databricks_v2_wire_route: wireRoute,
|
||
normalization_policy: rule.normalization_policy,
|
||
};
|
||
const matchExpr = buildTsMatchExpr(rule, provider);
|
||
arms.push(
|
||
` // rule: ${rule.id}, provider: ${provider}, priority: ${rule.match_priority}\n if (provider === "${provider}" && (${matchExpr})) {\n return ${emitTsCapabilityResult(clean, " ")};\n }`,
|
||
);
|
||
}
|
||
}
|
||
return arms.join("\n");
|
||
}
|
||
|
||
function buildTsMatchExpr(rule, provider) {
|
||
const allTokens = [rule.match_value, ...(rule.match_aliases ?? [])];
|
||
switch (rule.match_kind) {
|
||
case "exact":
|
||
return allTokens.map((t) => `lower === "${t.toLowerCase()}"`).join(" || ");
|
||
case "prefix":
|
||
return allTokens.map((t) => `lower.startsWith("${t.toLowerCase()}")`).join(" || ");
|
||
case "gpt5-token":
|
||
return allTokens
|
||
.map((t) => `gpt5TokenMatchesGenerated(lower, "${t.toLowerCase()}")`)
|
||
.join(" || ");
|
||
case "gpt5-base":
|
||
return allTokens
|
||
.map((t) => `gpt5BaseMatchesGenerated(lower, "${t.toLowerCase()}")`)
|
||
.join(" || ");
|
||
case "segment":
|
||
return allTokens
|
||
.map(
|
||
(t) =>
|
||
`lower.split(/[^a-z0-9]+/).includes("${t.toLowerCase()}")`,
|
||
)
|
||
.join(" || ");
|
||
case "segment-prefix":
|
||
return allTokens
|
||
.map(
|
||
(t) =>
|
||
`lower.split(/[^a-z0-9]+/).some(s => s.startsWith("${t.toLowerCase()}"))`,
|
||
)
|
||
.join(" || ");
|
||
default:
|
||
throw new Error(`unknown match_kind: ${rule.match_kind}`);
|
||
}
|
||
}
|
||
|
||
const tsProviderFallbacks = providerFallbackKeys
|
||
.map((provider) => {
|
||
const fb = manifest.provider_fallbacks[provider];
|
||
const blankClean = { ...fb.blank, registry_label: null };
|
||
const concClean = { ...fb.concrete_unknown, registry_label: null };
|
||
delete blankClean._provenance;
|
||
delete concClean._provenance;
|
||
return ` "${provider}": {
|
||
blank: ${emitTsCapabilityResult(blankClean, " ")},
|
||
concreteUnknown: ${emitTsCapabilityResult(concClean, " ")},
|
||
},`;
|
||
})
|
||
.join("\n");
|
||
|
||
const defaultFbBlank = { ...manifest.provider_fallbacks["_default"].blank, registry_label: null };
|
||
const defaultFbConc = {
|
||
...manifest.provider_fallbacks["_default"].concrete_unknown,
|
||
registry_label: null,
|
||
};
|
||
delete defaultFbBlank._provenance;
|
||
delete defaultFbConc._provenance;
|
||
|
||
const tsContent = `// biome-ignore-all format: generated — do not edit by hand.
|
||
// Regenerate with: node scripts/generate-model-capabilities.mjs
|
||
// Source: scripts/model-capabilities.json
|
||
//
|
||
// Resolver: provider+rawModelId → exact lookup → family rules → provider fallback (plan v4).
|
||
// Not owned here: OpenAI/legacy Databricks/OpenRouter transport; final labels (resolveModelLabel() authoritative).
|
||
|
||
/** Valid thinking-effort values accepted by buzz-agent (mirrors parse_thinking_effort in config.rs). */
|
||
export const THINKING_EFFORT_VALUES = [${VALID_EFFORTS.map((e) => `"${e}"`).join(", ")}] as const;
|
||
export type ThinkingEffortValue = (typeof THINKING_EFFORT_VALUES)[number];
|
||
|
||
/** Databricks v2 wire route. NotApplicable for non-DBv2 providers. */
|
||
export type DatabricksV2WireRoute =
|
||
| "openai-responses"
|
||
| "anthropic-messages"
|
||
| "mlflow-chat"
|
||
| "route-unknown"
|
||
| "not-applicable";
|
||
|
||
/** Anthropic thinking API shape for this model. */
|
||
export type ThinkingMode =
|
||
| "manual-budget"
|
||
| "adaptive"
|
||
| "omit-fields"
|
||
| "none"
|
||
| "not-applicable";
|
||
|
||
/** How to normalize effort values before sending to the provider. */
|
||
export type NormalizationPolicy = "none" | "openai-standard" | "openai-clamp-max-to-xhigh";
|
||
|
||
/** Complete resolved capability record for a (provider, rawModelId) pair. Every axis populated. */
|
||
export type CapabilityResult = {
|
||
/** Optional static display label. Feeds resolveModelLabel()'s registry tier only. */
|
||
readonly registryLabel: string | null;
|
||
readonly thinkingMode: ThinkingMode;
|
||
readonly supportedEfforts: ReadonlyArray<ThinkingEffortValue>;
|
||
readonly defaultEffort: ThinkingEffortValue | null;
|
||
readonly databricksV2WireRoute: DatabricksV2WireRoute;
|
||
readonly normalizationPolicy: NormalizationPolicy;
|
||
};
|
||
|
||
/** Valid thinking-effort values accepted by buzz-agent. */
|
||
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = THINKING_EFFORT_VALUES;
|
||
|
||
/** Databricks v2 known model IDs. Mirrors goose DATABRICKS_V2_KNOWN_MODELS. */
|
||
export const DATABRICKS_V2_KNOWN_MODELS = [
|
||
${manifest.databricks_v2_known_models
|
||
.map((id) => ` "${id}",`)
|
||
.join("\n")}
|
||
] as const;
|
||
|
||
/** Databricks endpoint-ID to display-name registry. Generated from manifest registry_labels section.
|
||
* Feeds the static registry tier of resolveModelLabel(). */
|
||
export const DATABRICKS_MODEL_NAMES: Map<string, string> = new Map([
|
||
${registryLabelsArr
|
||
.map(({ id, label }) => ` ["${id}", "${label}"],`)
|
||
.join("\n")}
|
||
]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// gpt5 boundary-aware token helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function gpt5TokenMatchesGenerated(m: string, token: string): boolean {
|
||
let start = 0;
|
||
while (true) {
|
||
const idx = m.indexOf(token, start);
|
||
if (idx === -1) return false;
|
||
const afterIdx = idx + token.length;
|
||
const afterChar = afterIdx < m.length ? m[afterIdx] : "";
|
||
if (afterChar === "" || afterChar === "-") return true;
|
||
start = afterIdx;
|
||
}
|
||
}
|
||
|
||
function gpt5BaseMatchesGenerated(m: string, token: string): boolean {
|
||
let start = 0;
|
||
while (true) {
|
||
const idx = m.indexOf(token, start);
|
||
if (idx === -1) return false;
|
||
const afterIdx = idx + token.length;
|
||
const suffix = m.slice(afterIdx);
|
||
if (suffix === "") return true;
|
||
if (!suffix.startsWith("-")) { start = afterIdx; continue; }
|
||
const dashRest = suffix.slice(1);
|
||
if (/^\\d{1,3}(?:[^a-z\\d]|$)/i.test(dashRest)) { start = afterIdx; continue; }
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Exact records — provider-qualified, pre-prefix-stripping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const EXACT_RECORDS = new Map<string, CapabilityResult>([
|
||
${tsExactEntries
|
||
.map(({ rec, clean }) => {
|
||
return ` ["${rec.provider}::${rec.raw_model_id}", ${emitTsCapabilityResult(clean, " ")}],`;
|
||
})
|
||
.join("\n")}
|
||
]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Provider fallbacks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PROVIDER_FALLBACKS: Record<string, { blank: CapabilityResult; concreteUnknown: CapabilityResult }> = {
|
||
${tsProviderFallbacks}
|
||
};
|
||
|
||
const DEFAULT_FALLBACK = {
|
||
blank: ${emitTsCapabilityResult(defaultFbBlank, " ")},
|
||
concreteUnknown: ${emitTsCapabilityResult(defaultFbConc, " ")},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Strip catalog prefix — finds first family token occurrence
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function stripCatalogPrefix(model: string): string {
|
||
const FAMILY_TOKENS = [${manifest.family_tokens.map((t) => `"${t}"`).join(", ")}] as const;
|
||
let firstIdx = Infinity;
|
||
for (const tok of FAMILY_TOKENS) {
|
||
const idx = model.toLowerCase().indexOf(tok);
|
||
if (idx !== -1 && idx < firstIdx) firstIdx = idx;
|
||
}
|
||
return firstIdx === Infinity ? model : model.slice(firstIdx);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Family rule resolver (generated ordered if-chain)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function lookupByFamilyRules(provider: string, normalized: string): CapabilityResult | null {
|
||
const lower = normalized.toLowerCase();
|
||
${emitTsFamilyResolver()}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Top-level resolve — total function, always returns a complete result
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Resolve (provider, rawModelId) → CapabilityResult.
|
||
*
|
||
* Total function — always returns a complete result. Consumers never compose
|
||
* fields from multiple tiers at runtime.
|
||
*
|
||
* Resolution order (plan v4):
|
||
* 1. Provider-qualified raw exact lookup (before prefix stripping)
|
||
* 2. Provider-scoped family rules on normalized alias
|
||
* 3. Per-axis provider fallback (blank vs concrete-unknown)
|
||
*/
|
||
export function resolveModelCapabilities(
|
||
provider: string,
|
||
rawModelId: string,
|
||
): CapabilityResult {
|
||
// Step 1: raw exact lookup
|
||
const exactKey = \`\${provider}::\${rawModelId}\`;
|
||
const exact = EXACT_RECORDS.get(exactKey);
|
||
if (exact) return exact;
|
||
|
||
// Step 2: family rules on normalized alias
|
||
const normalized = stripCatalogPrefix(rawModelId);
|
||
const family = lookupByFamilyRules(provider, normalized);
|
||
if (family) return family;
|
||
|
||
// Step 3: provider fallback
|
||
const isBlank = rawModelId.trim() === "";
|
||
const fb = PROVIDER_FALLBACKS[provider] ?? DEFAULT_FALLBACK;
|
||
return isBlank ? { ...fb.blank, registryLabel: null } : { ...fb.concreteUnknown, registryLabel: null };
|
||
}
|
||
`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Write or check files
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Outputs are written after the pricing axis is built below — see outputsWithPricing.
|
||
// (The outputs array is kept here as a reference; actual writing uses outputsWithPricing.)
|
||
const _capabilityOnlyOutputs = [finalRustContent, tsContent]; // referenced below
|
||
let checkFailed = false; // used by the pricing write loop below
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pricing axis — exact (authority, model) lookup, no inference
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Pricing is keyed on (billing-authority domain, exact model string) — both
|
||
// as returned by the provider API. No prefix matching, no normalization,
|
||
// no catalog-prefix stripping. "No applicable price" is a complete result.
|
||
//
|
||
// Databricks routes are corporate-internal and are not represented here.
|
||
// The publisher (P2 scope) must omit PricingIdentity for those routes.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const pricingRecords = manifest.pricing_records ?? [];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pricing-record validation — every entry must be structurally complete.
|
||
// No silent skips; any non-conforming entry is a hard error.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Registered billing-namespace identifiers (closed set; extends only by NIP amendment). */
|
||
const REGISTERED_AUTHORITIES = new Set([
|
||
"api.anthropic.com",
|
||
"api.openai.com",
|
||
"openrouter.ai",
|
||
]);
|
||
|
||
/**
|
||
* Return true if the string contains any character that is unsafe in a
|
||
* generated Rust string literal or a TS composite key: control characters
|
||
* (U+0000–U+001F, U+007F), double-quotes, backslashes, or NUL.
|
||
*/
|
||
function hasUnsafeKeyChars(s) {
|
||
return /[\x00-\x1f\x7f"\\]/.test(s);
|
||
}
|
||
|
||
for (let i = 0; i < pricingRecords.length; i++) {
|
||
const rec = pricingRecords[i];
|
||
const loc = `pricing_records[${i}]`;
|
||
|
||
// Every entry must have authority and model — no comment-only entries allowed
|
||
// in the records array (top-level _comment_pricing is a separate key).
|
||
if (typeof rec.authority !== "string" || rec.authority.length === 0) {
|
||
throw new Error(`${loc}: missing or empty "authority" — every pricing record must have a registered authority`);
|
||
}
|
||
if (!REGISTERED_AUTHORITIES.has(rec.authority)) {
|
||
throw new Error(
|
||
`${loc}: unregistered authority "${rec.authority}". ` +
|
||
`Allowed values: ${[...REGISTERED_AUTHORITIES].join(", ")}. ` +
|
||
`The set extends only by NIP amendment.`,
|
||
);
|
||
}
|
||
if (typeof rec.model !== "string" || rec.model.length === 0) {
|
||
throw new Error(`${loc} (${rec.authority}): missing or empty "model"`);
|
||
}
|
||
if (hasUnsafeKeyChars(rec.authority)) {
|
||
throw new Error(`${loc}: authority "${rec.authority}" contains unsafe characters (control chars, quotes, backslashes)`);
|
||
}
|
||
if (hasUnsafeKeyChars(rec.model)) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): model contains unsafe characters (control chars, quotes, backslashes)`);
|
||
}
|
||
|
||
// usd_per_mtok must be present with finite nonnegative input and output
|
||
const p = rec.usd_per_mtok;
|
||
if (!p || typeof p !== "object") {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): missing usd_per_mtok`);
|
||
}
|
||
if (typeof p.input !== "number" || !isFinite(p.input) || p.input < 0) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): usd_per_mtok.input must be a finite nonnegative number`);
|
||
}
|
||
if (typeof p.output !== "number" || !isFinite(p.output) || p.output < 0) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): usd_per_mtok.output must be a finite nonnegative number`);
|
||
}
|
||
for (const cacheField of ["cache_read", "cache_write"]) {
|
||
if (!Object.hasOwn(p, cacheField)) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): usd_per_mtok.${cacheField} is required (use null for unknown)`);
|
||
}
|
||
const v = p[cacheField];
|
||
if (v !== null) {
|
||
if (typeof v !== "number" || !isFinite(v) || v < 0) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): usd_per_mtok.${cacheField} must be null or a finite nonnegative number`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Provenance: _source must be present and non-empty
|
||
if (typeof rec._source !== "string" || rec._source.length === 0) {
|
||
throw new Error(`${loc} (${rec.authority}, ${rec.model}): missing "_source" provenance field`);
|
||
}
|
||
}
|
||
|
||
// Duplicate detection on the exact emitted (authority, model) identity — no transforms applied.
|
||
const pricingKeysSeen = new Set();
|
||
for (const rec of pricingRecords) {
|
||
const key = `${rec.authority}\0${rec.model}`;
|
||
if (pricingKeysSeen.has(key)) {
|
||
throw new Error(`duplicate pricing record for (${rec.authority}, ${rec.model})`);
|
||
}
|
||
pricingKeysSeen.add(key);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rust pricing generation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function rustOptionFloat(v) {
|
||
if (v === null || v === undefined) return "None";
|
||
const f = Number(v);
|
||
const s = Number.isInteger(f) ? `${f}.0_f64` : `${f}_f64`;
|
||
return `Some(${s})`;
|
||
}
|
||
|
||
function rustFloat(v) {
|
||
const f = Number(v);
|
||
return Number.isInteger(f) ? `${f}.0` : `${f}`;
|
||
}
|
||
|
||
function emitRustPricingResult(p, indent = " ") {
|
||
const i = indent;
|
||
return [
|
||
`${i}ModelPricing {`,
|
||
`${i} input_usd_per_mtok: ${rustFloat(p.input)},`,
|
||
`${i} output_usd_per_mtok: ${rustFloat(p.output)},`,
|
||
`${i} cache_read_usd_per_mtok: ${rustOptionFloat(p.cache_read)},`,
|
||
`${i} cache_write_usd_per_mtok: ${rustOptionFloat(p.cache_write)},`,
|
||
`${i}}`,
|
||
].join("\n");
|
||
}
|
||
|
||
// Each record becomes one arm in a nested match: authority → model → prices
|
||
// We group records by authority first for a readable nested match.
|
||
/** @type {Map<string, Array>} */
|
||
const byAuthority = new Map();
|
||
for (const rec of pricingRecords) {
|
||
const auth = rec.authority;
|
||
if (!byAuthority.has(auth)) byAuthority.set(auth, []);
|
||
byAuthority.get(auth).push(rec);
|
||
}
|
||
|
||
function buildRustPricingBody() {
|
||
const authArms = [];
|
||
for (const [auth, recs] of byAuthority) {
|
||
const modelArms = recs.map((rec) => {
|
||
const m = rec.model;
|
||
return (
|
||
` // (${auth}, ${m})\n` +
|
||
` "${m}" => return Some(\n${emitRustPricingResult(rec.usd_per_mtok, " ")}\n ),`
|
||
);
|
||
});
|
||
authArms.push(
|
||
` // authority: ${auth}\n` +
|
||
` "${auth}" => match model {\n${modelArms.join("\n")}\n _ => {}\n },`
|
||
);
|
||
}
|
||
return authArms.join("\n");
|
||
}
|
||
|
||
const rustPricingContent = `
|
||
// ---------------------------------------------------------------------------
|
||
// Pricing axis — exact (authority, model) lookup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-model token pricing in USD per million tokens.
|
||
/// All fields are USD / 1,000,000 tokens.
|
||
///
|
||
/// \`None\` fields mean the provider has not published a price for that token
|
||
/// category — never treat them as zero.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct ModelPricing {
|
||
pub input_usd_per_mtok: f64,
|
||
pub output_usd_per_mtok: f64,
|
||
/// Cache-read price, if published.
|
||
pub cache_read_usd_per_mtok: Option<f64>,
|
||
/// Cache-write (cache creation) price, if published.
|
||
/// Scoped to the default ephemeral cache class.
|
||
pub cache_write_usd_per_mtok: Option<f64>,
|
||
}
|
||
|
||
/// Look up pricing for an exact (billing authority, model) pair.
|
||
///
|
||
/// * \`authority\` — the registered billing-authority token, e.g.
|
||
/// \`"api.anthropic.com"\`, \`"api.openai.com"\`. This is NOT the
|
||
/// runtime transport \`Provider\` enum; it is the proven billing namespace.
|
||
/// Match is exact — the caller must supply the exact registered token.
|
||
/// * \`model\` — the exact model string returned by the provider API
|
||
/// in its response body. Match is exact — no normalization applied.
|
||
///
|
||
/// Returns \`None\` when the (authority, model) pair has no pricing record
|
||
/// — callers MUST treat \`None\` as "price unknown", never as "price zero".
|
||
/// Unknown authorities and unknown models both return \`None\`; there is no
|
||
/// family, prefix, or case-folding fallback.
|
||
pub fn lookup_pricing(authority: &str, model: &str) -> Option<ModelPricing> {
|
||
match authority {
|
||
${buildRustPricingBody()}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
`;
|
||
|
||
const finalRustPricingContent = rustfmt(rustContent + rustGpt5Helpers + rustPricingContent);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TypeScript pricing generation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function tsPricingField(v) {
|
||
if (v === null || v === undefined) return "null";
|
||
return `${v}`;
|
||
}
|
||
|
||
function emitTsPricingResult(p, indent = " ") {
|
||
const i = indent;
|
||
return [
|
||
`{`,
|
||
`${i} inputUsdPerMtok: ${p.input},`,
|
||
`${i} outputUsdPerMtok: ${p.output},`,
|
||
`${i} cacheReadUsdPerMtok: ${tsPricingField(p.cache_read)},`,
|
||
`${i} cacheWriteUsdPerMtok: ${tsPricingField(p.cache_write)},`,
|
||
`${i}}`,
|
||
].join(`\n${i}`);
|
||
}
|
||
|
||
// Build TS pricing map entries: key is `${authority}\0${model}` (exact, no transforms)
|
||
const tsPricingMapEntries = pricingRecords
|
||
.map((rec) => {
|
||
const key = `${rec.authority}\0${rec.model}`;
|
||
return ` [${JSON.stringify(key)}, ${emitTsPricingResult(rec.usd_per_mtok, " ")}],`;
|
||
})
|
||
.join("\n");
|
||
|
||
const tsPricingContent = `
|
||
// ---------------------------------------------------------------------------
|
||
// Pricing axis — exact (authority, model) lookup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Per-model token pricing, USD per million tokens. null = price not published. */
|
||
export type ModelPricing = {
|
||
/** Input (fresh, non-cache) token price, USD/MTok. */
|
||
readonly inputUsdPerMtok: number;
|
||
/** Output token price, USD/MTok. */
|
||
readonly outputUsdPerMtok: number;
|
||
/** Cache-read token price, USD/MTok. null when provider has not published it. */
|
||
readonly cacheReadUsdPerMtok: number | null;
|
||
/**
|
||
* Cache-write (cache creation) token price, USD/MTok. null when not published.
|
||
* Scoped to the default ephemeral cache class.
|
||
*/
|
||
readonly cacheWriteUsdPerMtok: number | null;
|
||
};
|
||
|
||
/**
|
||
* Composite key for pricing lookup: \`\${authority}\\0\${model}\` (exact strings, no transforms).
|
||
*
|
||
* authority — registered billing-authority token (e.g. "api.anthropic.com").
|
||
* model — exact model string from the provider API response body.
|
||
*/
|
||
const PRICING_TABLE: Map<string, ModelPricing> = new Map([
|
||
${tsPricingMapEntries}
|
||
]);
|
||
|
||
/**
|
||
* Look up pricing for an exact (billing authority, model) pair.
|
||
*
|
||
* Both arguments are matched exactly as supplied — no case normalization.
|
||
* The caller must supply the exact registered authority token and the exact
|
||
* provider-API model string. Returns \`null\` for unrecognised pairs — callers
|
||
* MUST treat null as "price unknown", never as zero cost. There is no family,
|
||
* prefix, or case-folding fallback.
|
||
*
|
||
* @param authority - Registered billing-authority token, e.g. "api.anthropic.com".
|
||
* This is NOT the runtime provider name; it is the proven billing namespace.
|
||
* @param model - Exact model string returned by the provider API response body.
|
||
*/
|
||
export function lookupModelPricing(authority: string, model: string): ModelPricing | null {
|
||
const key = \`\${authority}\\0\${model}\`;
|
||
return PRICING_TABLE.get(key) ?? null;
|
||
}
|
||
`;
|
||
|
||
// Standalone pricing TS file that does NOT import from modelCapabilities
|
||
// (no normalization or catalog-prefix logic needed)
|
||
const tsPricingFileContent = `// biome-ignore-all format: generated — do not edit by hand.
|
||
// Regenerate with: node scripts/generate-model-capabilities.mjs
|
||
// Source: scripts/model-capabilities.json
|
||
//
|
||
// Pricing axis: exact (authority, model) lookup for cost estimation.
|
||
// Consumers: agent-usage aggregation (Phase 4 of Usage v2 plan).
|
||
${tsPricingContent}`;
|
||
|
||
const outputsWithPricing = [
|
||
{
|
||
path: outputDirOverride
|
||
? join(outputDirOverride, "generated_model_capabilities.rs")
|
||
: join(repoRoot, "crates", "buzz-agent", "src", "generated_model_capabilities.rs"),
|
||
content: finalRustPricingContent,
|
||
label: "Rust (with pricing)",
|
||
},
|
||
{
|
||
path: outputDirOverride
|
||
? join(outputDirOverride, "modelCapabilities.ts")
|
||
: join(repoRoot, "desktop", "src", "features", "agents", "ui", "modelCapabilities.ts"),
|
||
content: tsContent,
|
||
label: "TypeScript",
|
||
},
|
||
{
|
||
path: outputDirOverride
|
||
? join(outputDirOverride, "modelPricing.ts")
|
||
: join(repoRoot, "desktop", "src", "features", "agents", "ui", "modelPricing.ts"),
|
||
content: tsPricingFileContent,
|
||
label: "TypeScript Pricing",
|
||
},
|
||
];
|
||
|
||
let checkFailedPricing = false;
|
||
for (const { path, content, label } of outputsWithPricing) {
|
||
if (CHECK_MODE) {
|
||
if (!existsSync(path)) {
|
||
console.error(`CHECK FAILED: ${label} file does not exist: ${path}`);
|
||
checkFailedPricing = true;
|
||
continue;
|
||
}
|
||
const existing = readFileSync(path, "utf8");
|
||
if (existing !== content) {
|
||
console.error(`CHECK FAILED: ${label} file is stale: ${path}`);
|
||
console.error("Run: node scripts/generate-model-capabilities.mjs to regenerate.");
|
||
checkFailedPricing = true;
|
||
} else {
|
||
console.log(`OK: ${label}`);
|
||
}
|
||
} else {
|
||
writeFileSync(path, content, "utf8");
|
||
console.log(`Wrote ${label}: ${path}`);
|
||
}
|
||
}
|
||
|
||
if ((CHECK_MODE && checkFailed) || (CHECK_MODE && checkFailedPricing)) {
|
||
process.exit(1);
|
||
}
|
||
if (!CHECK_MODE) {
|
||
console.log("Done. Generated 3 files (Rust capabilities + TS capabilities + TS pricing).");
|
||
}
|