import {readFile,writeFile} from 'node:fs/promises' import {pathToFileURL} from 'node:url' const keys=(value,allowed)=>Object.keys(value).filter(key=>!allowed.includes(key)) const safeId=value=>typeof value==='string'&&/^[a-z][a-z0-9-]{0,63}$/.test(value) export function validateScenario(value){ const errors=[] if(!value||typeof value!=='object'||Array.isArray(value))return['scenario must be an object'] for(const key of keys(value,['schema','synthetic','requests']))errors.push(`unknown scenario field: ${key}`) if(value.schema!=='martybytes-synthetic-gateway/v1')errors.push('schema must be martybytes-synthetic-gateway/v1') if(value.synthetic!==true)errors.push('synthetic must be true') if(!Array.isArray(value.requests)||value.requests.length===0)errors.push('requests must be a nonempty array') else { const requestIds=new Set() value.requests.forEach((request,index)=>{ const prefix=`requests[${index}]` if(!request||typeof request!=='object'||Array.isArray(request)){errors.push(`${prefix} must be an object`);return} for(const key of keys(request,['requestId','budgetUnits','attempts']))errors.push(`${prefix} unknown field: ${key}`) if(!safeId(request.requestId))errors.push(`${prefix}.requestId must be a safe stable id`) else if(requestIds.has(request.requestId))errors.push(`${prefix}.requestId must be unique`) else requestIds.add(request.requestId) if(!Number.isInteger(request.budgetUnits)||request.budgetUnits<0)errors.push(`${prefix}.budgetUnits must be a nonnegative integer`) if(!Array.isArray(request.attempts)||request.attempts.length===0)errors.push(`${prefix}.attempts must be a nonempty array`) else { const providers=new Set() request.attempts.forEach((attempt,attemptIndex)=>{ const at=`${prefix}.attempts[${attemptIndex}]` if(!attempt||typeof attempt!=='object'||Array.isArray(attempt)){errors.push(`${at} must be an object`);return} for(const key of keys(attempt,['provider','model','outcome','syntheticDurationTicks','estimate']))errors.push(`${at} unknown field: ${key}`) if(!safeId(attempt.provider))errors.push(`${at}.provider must be a safe id`) else if(providers.has(attempt.provider))errors.push(`${at}.provider must be unique per request`) else providers.add(attempt.provider) if(!safeId(attempt.model))errors.push(`${at}.model must be a safe id`) if(!['success','timeout','rate-limit','error'].includes(attempt.outcome))errors.push(`${at}.outcome is invalid`) if(!Number.isInteger(attempt.syntheticDurationTicks)||attempt.syntheticDurationTicks<0)errors.push(`${at}.syntheticDurationTicks must be a nonnegative integer`) const estimate=attempt.estimate if(!estimate||typeof estimate!=='object'||Array.isArray(estimate))errors.push(`${at}.estimate must be an object`) else { for(const key of keys(estimate,['units','source','basis']))errors.push(`${at}.estimate unknown field: ${key}`) if(!Number.isInteger(estimate.units)||estimate.units<0)errors.push(`${at}.estimate.units must be a nonnegative integer`) if(!safeId(estimate.source))errors.push(`${at}.estimate.source must be a safe id`) if(typeof estimate.basis!=='string'||estimate.basis.trim().length<4)errors.push(`${at}.estimate.basis must explain provenance`) } }) } }) } return errors } export function runScenario(scenario){ const errors=validateScenario(scenario) if(errors.length)throw new Error(errors.join('; ')) return {schema:'martybytes-synthetic-gateway-result/v1',synthetic:true,requests:scenario.requests.map(request=>{ let usedUnits=0 const attempts=[] let finalOutcome='exhausted' let selectedProvider=null for(let index=0;indexrequest.budgetUnits){ finalOutcome='budget-stopped' attempts.push({attempt:index+1,provider:candidate.provider,model:candidate.model,outcome:'not-attempted-budget-stop',syntheticDurationTicks:0,estimate:candidate.estimate,cumulativeEstimateUnits:usedUnits}) break } usedUnits+=candidate.estimate.units attempts.push({attempt:index+1,provider:candidate.provider,model:candidate.model,outcome:candidate.outcome,syntheticDurationTicks:candidate.syntheticDurationTicks,estimate:candidate.estimate,cumulativeEstimateUnits:usedUnits}) if(candidate.outcome==='success'){finalOutcome='success';selectedProvider=candidate.provider;break} } return {requestId:request.requestId,budgetUnits:request.budgetUnits,finalOutcome,selectedProvider,totalEstimatedUnits:usedUnits,attempts} })} } async function main(){ const [inputPath,outputPath]=process.argv.slice(2) if(!inputPath||!outputPath)throw new Error('Usage: node gateway-attribution-lab.mjs scenario.json result.json') const scenario=JSON.parse(await readFile(inputPath,'utf8')) const result=runScenario(scenario) await writeFile(outputPath,`${JSON.stringify(result,null,2)}\n`,{encoding:'utf8',flag:'wx'}) process.stdout.write(`${JSON.stringify(result,null,2)}\n`) } if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(error=>{process.stderr.write(`${JSON.stringify({ok:false,error:error.message})}\n`);process.exitCode=1})