feat: 建立版本化模型目录与规范字段生成链路 (#116)

This commit is contained in:
2026-10-03 11:05:56 -07:00
committed by GitHub
parent 4f4e5093e9
commit 7f4073a769
115 changed files with 108673 additions and 21 deletions

View File

@@ -0,0 +1,136 @@
/** 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`);
}