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

137 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Explicit ONE-TIME importer. Compilation never reads V1. Reimport is manual and reviewed. */
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const FACT_KEYS = ['contextWindow','maxInputTokens','maxOutputTokens','capabilities','serviceType','supportsReasoning','defaultTemperature','defaultTopP','defaultTopK'];
const INTRINSIC = new Set(['thinkingOnly','samplingParametersDeprecated','forcedToolChoiceUnsupported','speech','modelOrigin','dimensions','defaultDimension']);
const clone = x => structuredClone(x);
const equal = (a,b) => JSON.stringify(a) === JSON.stringify(b);
function readJson(file) { return JSON.parse(fs.readFileSync(file,'utf8')); }
function files(dir) { return fs.readdirSync(dir).filter(n => n.endsWith('.json') && !n.startsWith('_')).sort(); }
function leaves(obj, prefix='extra') { return Object.entries(obj ?? {}).flatMap(([k,v]) => v && typeof v === 'object' && !Array.isArray(v) ? leaves(v, `${prefix}.${k}`) : [`${prefix}.${k}`]); }
export function classifyExtra(key, consumers = []) {
if (/price|pricing/i.test(key)) return 'billing';
key = key.split('.')[0];
if(key==='responsesOnly')return 'request';
if (['deprecated','legacy','replacedBy','deprecationNotice','retiredAt'].includes(key)) return 'lifecycle';
if (INTRINSIC.has(key)) return 'intrinsic';
return consumers.length && !['thinkingDefault','supportsThinking','snapshot','aliases'].includes(key) ? 'request' : 'metadata';
}
function unitFor(key) {
if (key === 'cachePricing') return 'compound-source-units';
if (/PerMillionCharacters/.test(key)) return 'million-characters';
if (/write5m/.test(key)) return 'million-tokens-cache-write-5m';
if (/write1h/.test(key)) return 'million-tokens-cache-write-1h';
if (/searchRequestPriceUsd/.test(key)) return 'search-request';
if (/PerMinute/.test(key)) return 'minute';
if (/PerSong/.test(key)) return 'song-up-to-five-minutes';
if (/PerGeneration/.test(key)) return 'generation';
if (/PerImage/.test(key)) return 'image';
if (/Per1k/.test(key)) return 'thousand-requests';
if (/storagePerMillionTokensPerHour|StoragePricePerMillionTokenHour/.test(key)) return 'million-token-hour';
if (/Storage|storage/.test(key)) return 'source-unit-unresolved';
if (/Tiers/.test(key)) return 'tiered-source-unit';
if (/Note/.test(key)) return 'annotation';
return 'million-tokens';
}
export function importCatalog(repoRoot, consumers={}) {
const catalog = {formatVersion:2, publication:{state:'candidate',requiredClientVersion:null}, specs:[], accesses:[], retirements:[], metadata:{migrationEpoch:1, authority:'catalog/v2/catalog.json', verification:'migration-preserves-existing-evidence', specFiles:{}}};
for (const filename of files(path.join(repoRoot,'compute/model-specs'))) {
const data = readJson(path.join(repoRoot,'compute/model-specs',filename));
const vendor = filename.slice(0,-5);
catalog.metadata.specFiles[filename] = Object.fromEntries(Object.entries(data).filter(([k])=>k!=='specs'));
for (const entry of data.specs) {
const facts = Object.fromEntries(Object.entries(entry.spec).filter(([k])=>k!=='extra'));
const metadata = {recordedExtra:{}, projection:{specKeys:Object.keys(entry.spec),extraKeys:Object.keys(entry.spec.extra ?? {})}, sourceFile:`compute/model-specs/${filename}`};
for (const [key,value] of Object.entries(entry.spec.extra ?? {})) (INTRINSIC.has(key) ? (facts.constraints ??= {}) : metadata.recordedExtra)[key] = clone(value);
const rest = Object.fromEntries(Object.entries(entry).filter(([k])=> !['id','spec','routing'].includes(k)));
catalog.specs.push({modelRef:`${vendor}:${entry.id}`,identity:{vendor,canonicalModelId:entry.id},facts,routing:entry.routing ?? {},lifecycle:{status:'unknown'},metadata:{...metadata,legacyIdentity:rest}});
}
}
for(const spec of catalog.specs){
if(Object.hasOwn(spec.metadata.recordedExtra,'adaptiveThinking')){
spec.protocolProfiles=[{apiFormats:['anthropic-messages'],request:{adaptiveThinking:spec.metadata.recordedExtra.adaptiveThinking}}];
delete spec.metadata.recordedExtra.adaptiveThinking;
}
for(const key of ['releasedAt','retiredAt'])if(Object.hasOwn(spec.facts,key)){
(spec.metadata.recordedFacts??={})[key]=spec.facts[key];delete spec.facts[key];
}
}
const exactIndex = new Map();
for (const spec of catalog.specs) for (const name of [spec.identity.canonicalModelId,...(spec.metadata.legacyIdentity.match?.exact ?? [])]) {
const bucket=exactIndex.get(name) ?? []; if(!bucket.includes(spec)) bucket.push(spec); exactIndex.set(name,bucket);
}
for (const category of ['providers','coding-plans']) for (const filename of files(path.join(repoRoot,`compute/${category}`))) {
const data=readJson(path.join(repoRoot,`compute/${category}`,filename));
const connection=Object.fromEntries(Object.entries(data).filter(([k])=>k!=='models'));
const access={id:data.id,category,connection,models:[],metadata:{sourceFile:`compute/${category}/${filename}`}};
for (const model of data.models) {
let candidates=exactIndex.get(model.modelName) ?? [];
if(candidates.length>1) candidates=candidates.filter(s=>s.identity.vendor===data.provider);
const spec=candidates.length===1?candidates[0]:null;
const dynamic=/latest$|^auto$/.test(model.modelName);
const next={apiModelId:model.modelName,modelRef:spec?.modelRef ?? null,binding:{kind:spec?'exact':dynamic?'dynamic':'access-local',reason:spec?'exact-id-or-declared-alias':'no-proven-exact-identity'},overrides:{},request:{},billing:[],lifecycle:{status:'unknown'},metadata:{attributes:{},recordedExtra:{},projection:{modelKeys:Object.keys(model),extraKeys:Object.keys(model.extra ?? {})},sourceRecord:`${access.metadata.sourceFile}#${model.modelName}`}};
if(data.requiredClientVersion)next.requiredClientVersion=data.requiredClientVersion;
for(const [key,value] of Object.entries(model)) {
if(key==='modelName'||key==='extra')continue;
if(key==='apiModelId'){next.request.upstreamModelId=value;continue;}
if(['inputPrice','outputPrice'].includes(key)){next.billing.push({sourceKey:key,amount:value,currency:data.priceCurrency ?? 'unknown',unit:'million-tokens',direction:key==='inputPrice'?'input':'output',verification:'inherited-not-reverified'});continue;}
if(FACT_KEYS.includes(key)) {if(!spec||!equal(spec.facts[key],value))next.overrides[key]={value:clone(value),reason:'preserved-access-contract',evidence:next.metadata.sourceRecord,verification:'inherited-not-reverified'};} else next.metadata.attributes[key]=clone(value);
}
for(const [key,value] of Object.entries(model.extra ?? {})) {
const owner=classifyExtra(key,consumers[key] ?? []);
if(owner==='billing') next.billing.push({sourceKey:`extra.${key}`,amount:clone(value),currency:data.priceCurrency ?? 'unknown',unit:unitFor(key),verification:'inherited-not-reverified'});
else if(owner==='request') next.request[key]=clone(value);
else if(owner==='intrinsic') {
// Only original proven spec facts are shared; access-only declarations remain explicit constraints.
next.request.constraints ??= {}; next.request.constraints[key]=clone(value);
} else if(owner==='lifecycle') next.lifecycle[key]=clone(value);
else next.metadata.recordedExtra[key]=clone(value);
}
if(spec) for(const [key,value] of Object.entries(spec.metadata.recordedExtra)) {
if(classifyExtra(key,consumers[key]??[])==='request' && !Object.hasOwn(next.request,key)) next.request[key]=clone(value);
}
if(next.request.serverSideWebSearch && Object.hasOwn(next.request.serverSideWebSearch,'searchRequestPriceUsd')){
next.billing.push({sourceKey:'extra.serverSideWebSearch.searchRequestPriceUsd',amount:next.request.serverSideWebSearch.searchRequestPriceUsd,currency:'USD',unit:'search-request',verification:'inherited-not-reverified'});
delete next.request.serverSideWebSearch.searchRequestPriceUsd;
}
access.models.push(next);
}
catalog.accesses.push(access);
}
const decisionsPath=path.join(repoRoot,'docs/plans/catalog-contract-migration/SPEC-DECISIONS.json');
if(fs.existsSync(decisionsPath)){
const decisions=readJson(decisionsPath);
for(const access of catalog.accesses)for(const model of access.models){
const exception=(decisions.strictExactExceptions??[]).find(d=>d.file===access.metadata.sourceFile && d.apiModelId===model.apiModelId);
if(exception){model.modelRef=null;model.binding={kind:exception.kind?.includes('dynamic')?'dynamic':'access-local',reason:exception.reason};model.metadata.automaticRouting=false;}
const budget=(decisions.independentInputBudgets??[]).find(d=>d.file===access.metadata.sourceFile && d.apiModelId===model.apiModelId);
if(budget){
model.metadata.compatibilityValues={contextWindow:model.overrides.contextWindow?.value??catalog.specs.find(s=>s.modelRef===model.modelRef)?.facts.contextWindow};
for(const key of ['maxInputTokens','contextWindow','maxOutputTokens'])if(Object.hasOwn(budget,key))model.overrides[key]={value:budget[key],reason:budget.semanticDecision,evidence:budget.evidence,verification:'officially-verified-independent-budget'};
model.metadata.independentBudgetEvidence=budget;
}
}
}
return catalog;
}
export function discoverConsumers(clientRoot) {
const result={};
function scan(dir){if(!fs.existsSync(dir))return; for(const ent of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,ent.name); if(ent.isDirectory()){if(!['node_modules','__tests__','dist','.git'].includes(ent.name))scan(file);} else if(/\.[cm]?[tj]sx?$/.test(ent.name)&& !/test|spec\.ts/.test(ent.name)){const text=fs.readFileSync(file,'utf8');for(const m of text.matchAll(/\bextra(?:\?\.|\.)([a-zA-Z]\w*)/g)){(result[m[1]]??= []).push(path.relative(clientRoot,file));}}}}
scan(path.join(clientRoot,'packages'));scan(path.join(clientRoot,'app'));for(const k of Object.keys(result))result[k]=[...new Set(result[k])].sort();return result;
}
if(process.argv[1]===fileURLToPath(import.meta.url)){
if(!process.argv.includes('--write')) throw new Error('One-time migration requires --write; use compile.mjs thereafter');
const clientRoot=process.argv[process.argv.indexOf('--client-root')+1];
if(!process.argv.includes('--client-root'))throw new Error('Client source required for field consumer audit');
const consumers=discoverConsumers(clientRoot), catalog=importCatalog(root,consumers);
fs.mkdirSync(path.join(root,'catalog/v2'),{recursive:true});fs.writeFileSync(path.join(root,'catalog/v2/catalog.json'),JSON.stringify(catalog,null,2)+'\n');
const inventory = fs.readFileSync(path.join(root,'docs/plans/catalog-contract-migration/FIELD-INVENTORY.md'),'utf8');
const paths=[...inventory.matchAll(/`extra\.([^`]+)`/g)].map(m=>m[1]);
const decisions=paths.map(p=>({path:`extra.${p}`,owner:classifyExtra(p,consumers[p.split('.')[0]]??[]),consumerCandidates:consumers[p.split('.')[0]]??[],minimumVersion:null,versionStatus:'candidate-contract-not-released',verification:'static-property-read-candidate-not-runtime-acceptance'}));
fs.writeFileSync(path.join(root,'catalog/v2/field-decisions.json'),JSON.stringify(decisions,null,2)+'\n');
fs.writeFileSync(path.join(root,'docs/plans/catalog-contract-migration/FIELD-DECISIONS.md'),'# 字段归属与消费核查\n\n全部既有149条路径已分域;静态消费路径是核查候选,不宣称正式发布支持。无消费证明的值进入非运行时metadata,V1投影保留,不自动扩大能力。正式V2发布前须补实际客户端测试和版本。\n\n| 字段 | owner | 消费候选 | 发布版本 |\n| --- | --- | --- | --- |\n'+decisions.map(d=>`| \`${d.path}\` | ${d.owner} | ${d.consumerCandidates.slice(0,3).join('<br>')||'未识别;记录性隔离'} | 待正式发布 |`).join('\n')+'\n');
console.log(`Imported ${catalog.specs.length} specs, ${catalog.accesses.length} accesses, ${decisions.length} field decisions`);
}