Files
config-center/scripts/catalog-v2/compile.mjs

125 lines
11 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import {resolveEffectiveModel} from './effective-model.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
const clone=x=>structuredClone(x);
let canonicalValidator;
export function validateCanonicalSchema(catalog) {
if(!canonicalValidator){const ajv=new Ajv({allErrors:true,strict:false});addFormats(ajv);canonicalValidator=ajv.compile(JSON.parse(fs.readFileSync(path.join(root,'schemas/catalog-v2.schema.json'),'utf8')));}
if(!canonicalValidator(catalog))throw new Error(`Invalid V2 schema: ${JSON.stringify(canonicalValidator.errors)}`);
}
const REQUEST_KEYS=new Set(['adaptiveThinking','constraints','reasoning','serverSideWebSearch','thinking','thinkingRoundTrip','thinkingToolTurnValidation','endpointPath','apiFormatOverride','speech','responsesOnly','preserveThinkingDefault','upstreamModelId']);
export function stableJson(value){if(Array.isArray(value))return `[${value.map(stableJson).join(',')}]`;if(value&&typeof value==='object')return `{${Object.keys(value).sort().map(k=>`${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;return JSON.stringify(value);}
export function digest(value){return crypto.createHash('sha256').update(stableJson(value)).digest('hex');}
export function projectV1(catalog){
const outputs={};
for(const [filename,header] of Object.entries(catalog.metadata.specFiles)) outputs[`compute/model-specs/${filename}`]={...clone(header),specs:[]};
const byRef=new Map(catalog.specs.map(s=>[s.modelRef,s]));
for(const spec of catalog.specs){
const data={};for(const key of spec.metadata.projection.specKeys){
if(key==='extra'){data.extra={};for(const name of spec.metadata.projection.extraKeys){const bucket=Object.hasOwn(spec.facts.constraints??{},name)?spec.facts.constraints:spec.metadata.recordedExtra;const adaptiveProfile=spec.protocolProfiles?.find(p=>p.apiFormats.includes('anthropic-messages'));data.extra[name]=clone(name==='adaptiveThinking'&&adaptiveProfile?adaptiveProfile.request.adaptiveThinking:bucket[name]);}}
else data[key]=clone(Object.hasOwn(spec.metadata.recordedFacts??{},key)?spec.metadata.recordedFacts[key]:spec.facts[key]);
}
outputs[spec.metadata.sourceFile].specs.push({...clone(spec.metadata.legacyIdentity),id:spec.identity.canonicalModelId,spec:data,...(Object.keys(spec.routing).length?{routing:clone(spec.routing)}:{})});
}
for(const access of catalog.accesses){
const models=access.models.map(model=>{
const spec=byRef.get(model.modelRef), out={};
for(const key of model.metadata.projection.modelKeys){
if(key==='modelName')out.modelName=model.apiModelId;
else if(key==='apiModelId')out.apiModelId=model.request.upstreamModelId;
else if(key==='extra'){
out.extra={};for(const name of model.metadata.projection.extraKeys){
const price=model.billing.find(b=>b.sourceKey===`extra.${name}`);
if(price)out.extra[name]=clone(price.amount);
else if(Object.hasOwn(model.request,name))out.extra[name]=clone(model.request[name]);
else if(Object.hasOwn(model.request.constraints??{},name))out.extra[name]=clone(model.request.constraints[name]);
else if(Object.hasOwn(model.lifecycle,name))out.extra[name]=clone(model.lifecycle[name]);
else if(Object.hasOwn(model.metadata.recordedExtra,name))out.extra[name]=clone(model.metadata.recordedExtra[name]);
else throw new Error(`Missing projection ${model.apiModelId}.extra.${name}`);
for(const nested of model.billing.filter(b=>b.sourceKey.startsWith(`extra.${name}.`))){const keys=nested.sourceKey.slice(`extra.${name}.`.length).split('.');let target=out.extra[name];for(const part of keys.slice(0,-1))target=target[part]??={};target[keys.at(-1)]=clone(nested.amount);}
}
} else if(['inputPrice','outputPrice'].includes(key))out[key]=clone(model.billing.find(b=>b.sourceKey===key)?.amount);
else if(Object.hasOwn(model.metadata.compatibilityValues??{},key))out[key]=clone(model.metadata.compatibilityValues[key]);
else if(Object.hasOwn(model.overrides,key))out[key]=clone(model.overrides[key].value);
else if(Object.hasOwn(model.metadata.attributes,key))out[key]=clone(model.metadata.attributes[key]);
else if(Object.hasOwn(spec?.facts??{},key))out[key]=clone(spec.facts[key]);
else throw new Error(`Missing projection ${model.apiModelId}.${key}`);
}
return out;
});outputs[access.metadata.sourceFile]={...clone(access.connection),models};
}
return outputs;
}
export function validateCatalog(catalog){
validateCanonicalSchema(catalog);
const refs=new Set();
for(const spec of catalog.specs){if(refs.has(spec.modelRef))throw new Error(`Duplicate modelRef ${spec.modelRef}`);refs.add(spec.modelRef);if(Object.keys(spec.facts).some(k=>/price|billing/i.test(k)))throw new Error('Spec contains billing');}
const accessIds=new Set();
for(const access of catalog.accesses){if(accessIds.has(access.id))throw new Error(`Duplicate access ${access.id}`);accessIds.add(access.id);for(const model of access.models){
if(model.binding.kind==='exact'&&!refs.has(model.modelRef))throw new Error(`Unknown spec ${model.modelRef}`);
if(model.binding.kind!=='exact'&&model.modelRef!==null)throw new Error('Dynamic/unresolved cannot claim exact spec');
for(const [k,v]of Object.entries(model.overrides))if(!v.reason||!Object.hasOwn(v,'value'))throw new Error(`Unexplained override ${k}`);
for(const key of Object.keys(model.request))if(!REQUEST_KEYS.has(key))throw new Error(`Unknown managed request key ${key}`);
for(const key of ['reasoningEffort','defaultReasoningEffort','thinkingDefault','supportsThinking','cachedInputPrice','cacheHitPrice']) if(Object.hasOwn(model.request,key))throw new Error(`Old runtime key ${key}`);
for(const bill of model.billing)if(!bill.unit||!bill.currency)throw new Error('Billing dimension missing');
}}
if(catalog.publication.state==='published'&&!/^\d+\.\d+\.\d+$/.test(catalog.publication.requiredClientVersion??''))throw new Error('Published catalog needs real minimum client version');
}
export function compileCatalog(catalog){
validateCatalog(catalog);
const output=clone(catalog);
// Projection-only metadata never enters the runtime contract; useful descriptive values remain non-consumed.
delete output.metadata.specFiles;
for(const spec of output.specs){delete spec.metadata.projection;delete spec.metadata.legacyIdentity;}
for(const access of output.accesses)for(const model of access.models)delete model.metadata.projection;
output.digest=digest(output);
const projection=projectV1(catalog);
const diagnostics={specs:catalog.specs.length,accesses:catalog.accesses.length,models:catalog.accesses.reduce((n,a)=>n+a.models.length,0),bindings:{},overrides:[],publication:catalog.publication};
for(const a of catalog.accesses)for(const m of a.models){diagnostics.bindings[m.binding.kind]=(diagnostics.bindings[m.binding.kind]??0)+1;for(const [k,v]of Object.entries(m.overrides))diagnostics.overrides.push({accessId:a.id,apiModelId:m.apiModelId,field:k,...v});}
const specsByRef = new Map(output.specs.map(spec => [spec.modelRef,spec]));
const resolverParity={formatVersion:2,fixtureOnly:true,clientVersion:'999.0.0',requiredClientVersion:'0.0.0',now:'2026-10-03T00:00:00Z',models:[]};
for(const access of output.accesses)for(const model of access.models){
const effective=resolveEffectiveModel(specsByRef.get(model.modelRef),model,{publicationState:'published',requiredClientVersion:resolverParity.requiredClientVersion,clientVersion:resolverParity.clientVersion,now:resolverParity.now,connection:access.connection});
resolverParity.models.push({accessId:access.id,apiModelId:model.apiModelId,digest:digest(effective),selectable:effective.selectable,effectiveApiFormat:effective.apiFormat,effectiveApiModelId:effective.apiModelId,eligibleForCall:effective.eligibleForCall,eligibleForAutoRouting:effective.eligibleForAutoRouting,manualOnly:effective.manualOnly,blockedReasons:effective.blockedReasons});
}
return {output,projection,diagnostics,resolverParity};
}
/** Root V1 files are generated publication projections, never author inputs.
* Preserve harmless existing serialization when values match to avoid unrelated key-order churn.
*/
export function synchronizeRootProjection(repoRoot, projection, {check=false}={}) {
for(const [file,data] of Object.entries(projection)){
if(!/^compute\/(providers|coding-plans|model-specs)\/[^/]+\.json$/.test(file) || path.basename(file).startsWith('_'))throw new Error(`Invalid V1 publication path ${file}`);
const absolute=path.join(repoRoot,file);
let matches=false;
if(fs.existsSync(absolute)){
try{matches=stableJson(JSON.parse(fs.readFileSync(absolute,'utf8')))===stableJson(data);}catch{matches=false;}
}
if(check){if(!matches)throw new Error(`Stale V1 projection ${file}; edit catalog/v2 then run catalog:compile`);}
else if(!matches){fs.mkdirSync(path.dirname(absolute),{recursive:true});fs.writeFileSync(absolute,JSON.stringify(data,null,2)+'\n');}
}
}
export function run({check=false}={}){
const catalog=JSON.parse(fs.readFileSync(path.join(root,'catalog/v2/catalog.json'),'utf8'));
const retirementsPath=path.join(root,'catalog/v2/retirements.json');
if(fs.existsSync(retirementsPath)){const registry=JSON.parse(fs.readFileSync(retirementsPath,'utf8'));catalog.retirements=Array.isArray(registry)?registry:registry.retirements??registry.rules??[];}
const schema=JSON.parse(fs.readFileSync(path.join(root,'schemas/catalog-v2.schema.json'),'utf8'));
const ajv=new Ajv({allErrors:true,strict:false});addFormats(ajv);const validate=ajv.compile(schema);if(!validate(catalog))throw new Error(JSON.stringify(validate.errors));
const {output,projection,diagnostics,resolverParity}=compileCatalog(catalog);
const providerValidator=ajv.compile(JSON.parse(fs.readFileSync(path.join(root,'schemas/provider.schema.json'),'utf8')));
const specValidator=ajv.compile(JSON.parse(fs.readFileSync(path.join(root,'schemas/model-spec.schema.json'),'utf8')));
for(const [file,data]of Object.entries(projection)){const validator=file.includes('/model-specs/')?specValidator:providerValidator;if(!validator(data))throw new Error(`${file}: ${JSON.stringify(validator.errors)}`);}
synchronizeRootProjection(root,projection,{check});
const files={'compute/v2/resolver-parity.json':resolverParity,'compute/v2/catalog.json':output,'compute/v2/diagnostics.json':diagnostics,'compute/v2/manifest.json':{formatVersion:2,state:catalog.publication.state,requiredClientVersion:catalog.publication.requiredClientVersion,catalogPath:'compute/v2/catalog.json',digest:output.digest}};
for(const [file,data]of Object.entries(projection)) files[`compute/v2/compatibility/${file.slice('compute/'.length)}`]=data;
for(const [file,data]of Object.entries(files)){const absolute=path.join(root,file),text=JSON.stringify(data,null,2)+'\n';if(check){if(!fs.existsSync(absolute)||fs.readFileSync(absolute,'utf8')!==text)throw new Error(`Stale generated file ${file}`);}else {fs.mkdirSync(path.dirname(absolute),{recursive:true});fs.writeFileSync(absolute,text);}}
console.log(`V2 ${check?'verified':'compiled'}: ${diagnostics.models} models; state=${catalog.publication.state}; digest=${output.digest}`);
return diagnostics;
}
if(process.argv[1]===fileURLToPath(import.meta.url))run({check:process.argv.includes('--check')});