Commits agents/codex/work/queue-333/r1-repair/ (packet, receipts, review and author files) and the append-only BUILD-LOG.md and docs/SESSIONS.md entries. The packet INDEX.json sha256 is 3eb9b8d10445aa328be3b854385e4792dda2c5712b19d443d485d1b00a18d0f8 and source-tests.patch is a78bc385801b64be270fb9d6e84672c443738a5fce8eb68f7d2a01359a57a770; the product commit for these bytes is2627321f. Two packet tarballs are deliberately not committed: packet/baseline-input-bytes.tar.gz and packet/fixed-input-bytes.tar.gz. The secret check reported a -----BEGIN marker in each (members packages/bus/src/credentials.mjs and packages/discord/src/tools.mjs, byte-identical to503b91e56and with no PEM body). They stay untracked until the lead rules on them.
559 lines
40 KiB
Diff
559 lines
40 KiB
Diff
diff --git a/packages/bus/src/limits.mjs b/packages/bus/src/limits.mjs
|
|
index cb47c5df6c97fad2a18cf6a482b6976376ea7fa7..c4bb3a75ecd47b94f83dcc4df28f11e252d5f460 100644
|
|
--- a/packages/bus/src/limits.mjs
|
|
+++ b/packages/bus/src/limits.mjs
|
|
@@ -12,7 +12,7 @@
|
|
//
|
|
// refresh, mayLaunch and mustStop are async: they re-read the file and reload first. report is
|
|
// sync, so the organization verbs stay sync; it uses the modules the last refresh loaded.
|
|
-import { createLoader } from '../../plugins/src/loader.mjs';
|
|
+import { createLoader, isDailyModelQuota } from '../../plugins/src/loader.mjs';
|
|
import { turnRecords } from './usage.mjs';
|
|
|
|
const KIND = 'limit-check';
|
|
@@ -122,8 +122,9 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
|
|
log({ event: 'limit-check-failed', business, check: st.missing, export: name, code: 'not-loaded' });
|
|
return halt({ check: st.missing, code: 'not-loaded' });
|
|
}
|
|
+ if (!st.checks.length) return { [key]: go };
|
|
const leaves = limitLeaves(st.limits);
|
|
- if (Object.keys(st.limits?.tokens?.keys ?? {}).some(k => /^[a-f0-9]{12}$/.test(k))) {
|
|
+ if (Object.entries(st.limits?.tokens?.keys ?? {}).some(([k, entry]) => isDailyModelQuota(k, entry))) {
|
|
let modelKeyId;
|
|
try {
|
|
if (name === 'mustStop' && (typeof context?.run !== 'string' || !context.run)) throw new Failure('proxy-key-refused');
|
|
diff --git a/packages/bus/src/model-keys.mjs b/packages/bus/src/model-keys.mjs
|
|
index 560c8474838ca69a88539ecbc44961c2f157a84b..a998576434fa604912732b0131b9e31ca62f08b4 100644
|
|
--- a/packages/bus/src/model-keys.mjs
|
|
+++ b/packages/bus/src/model-keys.mjs
|
|
@@ -36,6 +36,17 @@ export class ModelKeys {
|
|
if (this.#record(instance)?.state === 'revoking') fail('proxy-key-revoking');
|
|
if (!this.#ready) fail(this.#failure);
|
|
}
|
|
+ // P5's destructive queue read needs this instance's current identity, not
|
|
+ // an issued agent credential. A sentinel refusal stays closed until explicit
|
|
+ // reconcile; telemetry never retries around it or performs reconciliation.
|
|
+ verifyIdentity() {
|
|
+ return this.#exclusive(async () => {
|
|
+ if (!this.#ready) fail(this.#failure);
|
|
+ let keys;
|
|
+ try { keys = await this.#client.getKeys(); } catch { fail('proxy-down'); }
|
|
+ this.#sentinel(keys);
|
|
+ });
|
|
+ }
|
|
async #replace(before, after) {
|
|
this.#sentinel(before); this.#sentinel(after);
|
|
await this.#client.putKeys(after);
|
|
diff --git a/packages/bus/src/proxy-usage.mjs b/packages/bus/src/proxy-usage.mjs
|
|
index 71d9a72fa348cf24003e02d36ebe0c1280597f30..754c8c022185014c39183dae0456ac6235ff87a9 100644
|
|
--- a/packages/bus/src/proxy-usage.mjs
|
|
+++ b/packages/bus/src/proxy-usage.mjs
|
|
@@ -28,7 +28,7 @@ export function createProxyUsage({ store, read, client, records, log, now = Date
|
|
due = now() + Math.min(...enabled.map(b => b.pollSeconds)) * 1000;
|
|
try {
|
|
await loader.load({ usage: 'cliproxy-queue' });
|
|
- const module = loader.modules('usage')[0], management = client();
|
|
+ const module = loader.modules('usage')[0], management = await client();
|
|
if (!module || !management) throw Error('unavailable');
|
|
const events = await module.read({ client: management });
|
|
const owners = new Map();
|
|
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
|
index c21103a447c2e77fa1b8e487fef1a7c4a0dba6b2..8bb0b0585df10f38d3bf563f3bb6b4dc15ef33ab 100644
|
|
--- a/packages/bus/src/runtime.mjs
|
|
+++ b/packages/bus/src/runtime.mjs
|
|
@@ -384,7 +384,11 @@ export async function startBroker({
|
|
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
|
|
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
|
|
proxyView: business => proxyUsage?.view(business) ?? null });
|
|
- proxyUsage = createProxyUsage({ store, log: usageLog, client: () => modelProxyClient,
|
|
+ proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
|
|
+ if (!modelKeys) return null;
|
|
+ await modelKeys.verifyIdentity();
|
|
+ return modelProxyClient;
|
|
+ },
|
|
records: () => credentials.status(),
|
|
read: () => Object.fromEntries(Object.keys(businesses).map(id => [id, settings ? settings.current(id) : businesses[id]])) });
|
|
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
|
@@ -862,9 +866,10 @@ export async function startBroker({
|
|
}
|
|
// One bounded queue read at a time. The service applies the configured
|
|
// 1..59 second poll deadline; this tick only checks whether a read is due.
|
|
- usageTimer = setInterval(() => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); }, 1000);
|
|
+ const usageTick = () => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); };
|
|
+ usageTimer = setInterval(usageTick, 1000);
|
|
usageTimer.unref();
|
|
- await proxyUsage.tick();
|
|
+ usageTick(); // Optional telemetry never blocks the native ready answer.
|
|
return {
|
|
broker,
|
|
credentials,
|
|
diff --git a/packages/bus/tests/limit-checks.test.mjs b/packages/bus/tests/limit-checks.test.mjs
|
|
index b9b7660723e4e2fe115cc786b8af2121b23e2403..a0264cc77cb2f9802d42adf4df5dbb01165a5bcc 100644
|
|
--- a/packages/bus/tests/limit-checks.test.mjs
|
|
+++ b/packages/bus/tests/limit-checks.test.mjs
|
|
@@ -327,7 +327,7 @@ test('REQ-LIMIT-3 core reaches limit checks only through the plug-in loader', ()
|
|
|
|
test('P5 missing, ambiguous or invalid model key lookup fails closed without leaking a lookup error', async t => {
|
|
const root=mkdtempSync(join(tmpdir(),'p5-limit-')),store=new Store(root),log=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
- const key='012345abcdef',doc={plugins:{'limit-check':['token-limit']},launch:{limits:{tokens:{keys:{[key]:{limit:5,since:SINCE}}}}}};
|
|
+ const key='012345abcdef',doc={plugins:{'limit-check':['daily-quota']},launch:{limits:{tokens:{keys:{[key]:{limit:5,since:SINCE,reset:'00:00',timeZone:'UTC'}}}}}};
|
|
for(const lookup of [()=>null,()=>['012345abcdef','abcdef012345'],()=> 'msk-private-lookup',()=>{throw Error('PRIVATE-LOOKUP-CANARY');}]) {
|
|
const l=createLimits({store,read:()=>doc,log:e=>log.push(e),keyForSubject:lookup});
|
|
assert.deepEqual(await l.mayLaunch('demo',SUBJECT),{allow:false,check:null,code:'proxy-key-refused'});
|
|
@@ -337,7 +337,7 @@ test('P5 missing, ambiguous or invalid model key lookup fails closed without lea
|
|
});
|
|
test('P5 trusted lookup uses the retiring run key rather than the agent current key',async t=>{
|
|
const root=mkdtempSync(join(tmpdir(),'p5-old-key-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
- const old='012345abcdef',current='abcdef012345',doc={plugins:{'limit-check':['token-limit']},launch:{limits:{tokens:{keys:{[old]:{limit:0,since:SINCE}}}}}};
|
|
+ const old='012345abcdef',current='abcdef012345',doc={plugins:{'limit-check':['daily-quota']},launch:{limits:{tokens:{keys:{[old]:{limit:0,since:SINCE,reset:'00:00',timeZone:'UTC'}}}}}};
|
|
const l=createLimits({store,read:()=>doc,log:()=>{},keyForSubject:(_,s)=>s.run==='old-run'?old:current});
|
|
assert.equal((await l.mayLaunch('demo',SUBJECT)).allow,true);
|
|
assert.equal((await l.mustStop('demo',SUBJECT,{run:'old-run'})).stop,true);
|
|
diff --git a/packages/bus/tests/p5-background.test.mjs b/packages/bus/tests/p5-background.test.mjs
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..bb51ad3527280f0db0449c27eb42474dd8b9e894
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/p5-background.test.mjs
|
|
@@ -0,0 +1,51 @@
|
|
+// Prepared after Mos1971 STOP. These controls have NOT been executed locally.
|
|
+// Only fake loopback HTTP, explicit event barriers and virtual existing timers.
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { createHash } from 'node:crypto';
|
|
+import { mkdirSync, writeFileSync } from 'node:fs';
|
|
+import { join } from 'node:path';
|
|
+import { startTimeOf } from '../../seat/src/proc.mjs';
|
|
+import { telemetryFixture } from './p5-telemetry-fixture.mjs';
|
|
+async function nativeTurn(f) {
|
|
+ const model=await f.bus.modelPreflight({business:'demo',role:'coder'});
|
|
+ const keyId=createHash('sha256').update(model.key).digest('hex').slice(0,12),dir=join(f.root,'native-transcript');mkdirSync(dir);
|
|
+ const {cap}=f.bus.bindLaunch({business:'demo',role:'coder',run:'held-poll-native',harness:'pi',modelKeyId:keyId,transcript:dir,
|
|
+ pid:process.pid,startTime:startTimeOf(process.pid)});
|
|
+ f.bus.broker.request(cap,{verb:'role.claim'});
|
|
+ assert.deepEqual(await f.bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'}),{allow:true});
|
|
+ const human=f.human(),{id}=f.bus.broker.request(human,{verb:'message.send',args:{to:'coder',body:'native work while optional queue is held'}});
|
|
+ f.bus.broker.request(cap,{verb:'message.receive'});
|
|
+ await f.bus.broker.request(cap,{verb:'usage.begin',args:{turn:id}});
|
|
+ writeFileSync(join(dir,'native.jsonl'),JSON.stringify({type:'message',id:'native',message:{role:'assistant',usage:{input:2,output:1,cacheRead:0,cacheWrite:0}}})+'\n');
|
|
+ await f.bus.broker.request(cap,{verb:'usage.end',args:{turn:id}});
|
|
+ const view=f.bus.broker.request(human,{verb:'usage'});
|
|
+ assert.equal(view.total.input,2);assert.equal(view.byKey[0].modelKeyId,keyId);
|
|
+ assert.deepEqual(f.bus.broker.request(cap,{verb:'message.receive'}),[]);
|
|
+}
|
|
+for(const fail of [false,true])test(`P5 R1 held first optional poll does not delay native startup or work; ${fail?'failure':'release'} settles safely`,async t=>{
|
|
+ t.mock.timers.enable({apis:['Date','setInterval'],now:Date.now()});
|
|
+ const f=await telemetryFixture(t,{hold:true});const started=f.start();
|
|
+ await f.waitQueue(1);await new Promise(setImmediate);
|
|
+ // Causal first guard: the queue is still held, so a ready runtime cannot
|
|
+ // have depended on its response. No racing timeout or longer budget.
|
|
+ assert.notEqual(f.bus,null,'startBroker must resolve while the first queue response is held');
|
|
+ await started;assert.equal(f.state.held.length,1);
|
|
+ assert.equal(f.bus.broker.request(f.human(),{verb:'plugins'}).plugins.usage,'cliproxy-queue');
|
|
+ await nativeTurn(f);assert.equal(f.queueCalls(),1);
|
|
+ t.mock.timers.tick(1000);await new Promise(setImmediate);assert.equal(f.queueCalls(),1,'scheduled ticks share the held first read');
|
|
+ const from=f.logs.length;f.release(fail);
|
|
+ await f.waitLog(fail?'proxy-usage-read-failed':'proxy.usage.reconcile',from);
|
|
+ assert.equal(f.bus.broker.request(f.human(),{verb:'usage'}).proxy.state,fail?'unavailable':'available');
|
|
+ await f.close();assert.equal(f.queueCalls(),1);
|
|
+});
|
|
+test('P5 R1 close bounds an outstanding first poll by the unchanged five-second client deadline',async t=>{
|
|
+ t.mock.timers.enable({apis:['Date','setInterval','setTimeout'],now:Date.now()});
|
|
+ const f=await telemetryFixture(t,{hold:true});const started=f.start();await f.waitQueue(1);await new Promise(setImmediate);
|
|
+ assert.notEqual(f.bus,null,'native readiness precedes the held optional poll');await started;
|
|
+ let closed=false;const closing=f.close().then(()=>{closed=true;});
|
|
+ t.mock.timers.tick(4999);await new Promise(setImmediate);assert.equal(closed,false);
|
|
+ t.mock.timers.tick(1);await closing;
|
|
+ assert.equal(closed,true);assert.equal(f.queueCalls(),1);
|
|
+ assert.ok(f.logs.some(line=>line.includes('proxy-usage-read-failed')));
|
|
+});
|
|
diff --git a/packages/bus/tests/p5-identity.test.mjs b/packages/bus/tests/p5-identity.test.mjs
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..4ee111f7c9ec75dbae24c184c3310b71973fc7c2
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/p5-identity.test.mjs
|
|
@@ -0,0 +1,38 @@
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { telemetryFixture } from './p5-telemetry-fixture.mjs';
|
|
+for(const foreign of [true,false])test(`P5 R1 boot identity ${foreign?'refusal prevents queue consumption':'matching sentinel permits telemetry without an agent key'}`,async t=>{
|
|
+ t.mock.timers.enable({apis:['Date','setInterval'],now:Date.now()});
|
|
+ const f=await telemetryFixture(t,{foreign});await f.start();await f.settleInitial();
|
|
+ const human=f.human();assert.equal(f.bus.broker.request(human,{verb:'plugins'}).plugins.usage,'cliproxy-queue');
|
|
+ if(foreign){
|
|
+ await assert.rejects(f.bus.modelPreflight({business:'demo',role:'coder'}),{code:'proxy-open'});
|
|
+ assert.equal(f.queueCalls(),0);assert.equal(f.state.queued.length,1);
|
|
+ await f.pollSettledAfterTick(t);assert.equal(f.queueCalls(),0);assert.equal(f.state.queued.length,1);
|
|
+ assert.equal(f.bus.broker.request(human,{verb:'usage'}).proxy.state,'unavailable');
|
|
+ }else{
|
|
+ assert.equal(f.queueCalls(),1);assert.equal(f.state.queued.length,0);
|
|
+ assert.equal(f.bus.broker.request(human,{verb:'usage'}).proxy.state,'available');
|
|
+ assert.equal(f.bus.credentials.status().some(r=>r.service==='model'&&r.keyId),false);
|
|
+ }
|
|
+});
|
|
+test('P5 R1 later sentinel refusal fences every subsequent queue poll while preserving human control',async t=>{
|
|
+ t.mock.timers.enable({apis:['Date','setInterval'],now:Date.now()});
|
|
+ const f=await telemetryFixture(t);await f.start();await f.settleInitial();assert.equal(f.queueCalls(),1);
|
|
+ f.state.keys=[f.other];
|
|
+ await assert.rejects(f.bus.modelPreflight({business:'demo',role:'coder'}),{code:'proxy-open'});
|
|
+ await f.pollSettledAfterTick(t);assert.equal(f.queueCalls(),1);
|
|
+ await f.pollSettledAfterTick(t);assert.equal(f.queueCalls(),1);
|
|
+ const human=f.human();assert.equal(f.bus.broker.request(human,{verb:'plugins'}).plugins.usage,'cliproxy-queue');
|
|
+ assert.equal(f.bus.broker.request(human,{verb:'usage'}).proxy.state,'unavailable');
|
|
+});
|
|
+
|
|
+test('P5 R1 every drain verifies fresh sentinel before consuming a changed listener',async t=>{
|
|
+ t.mock.timers.enable({apis:['Date','setInterval'],now:Date.now()});
|
|
+ const f=await telemetryFixture(t);await f.start();await f.settleInitial();assert.equal(f.queueCalls(),1);
|
|
+ f.state.keys=[f.other];await f.pollSettledAfterTick(t);assert.equal(f.queueCalls(),1);
|
|
+ await assert.rejects(f.bus.modelPreflight({business:'demo',role:'coder'}),{code:'proxy-open'});
|
|
+ const lists=f.state.calls.filter(p=>p==='/v8/management/config/access/api-keys').length;
|
|
+ f.state.keys=[f.own];await f.pollSettledAfterTick(t);assert.equal(f.queueCalls(),1);
|
|
+ assert.equal(f.state.calls.filter(p=>p==='/v8/management/config/access/api-keys').length,lists,'no retry around sticky identity refusal');
|
|
+});
|
|
diff --git a/packages/bus/tests/p5-key-compatibility.test.mjs b/packages/bus/tests/p5-key-compatibility.test.mjs
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..331032ecb48644587057a9ec4799e69d5fa3cdc8
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/p5-key-compatibility.test.mjs
|
|
@@ -0,0 +1,68 @@
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
+import { tmpdir } from 'node:os';
|
|
+import { join } from 'node:path';
|
|
+import { Store } from '../src/store.mjs';
|
|
+import { createLimits, modelUsageKey } from '../src/limits.mjs';
|
|
+import * as token from '../../plugins/limit-check/token-limit/index.mjs';
|
|
+import * as daily from '../../plugins/limit-check/daily-quota/index.mjs';
|
|
+import { launchSettings } from '../../business/src/launch-settings.mjs';
|
|
+import { businessDoc, rolesCopy, writeJson, systemFor } from '../../business/tests/helpers.mjs';
|
|
+const key='abcdefabcdef',other='012345abcdef',since='2026-01-01T00:00:00.000Z';
|
|
+const quota={limit:10,since,reset:'00:00',timeZone:'UTC'};
|
|
+const native={at:'2026-10-26T12:00:00.000Z',run:'legacy-run',turn:'turn',account:key,modelKeyId:other,status:'known',input:10,output:0,cacheWrite:0,cacheRead:0};
|
|
+test('P5 R1 plain twelve-hex account leaves retain matching and original account report even with another model ID',()=>{
|
|
+ const a={limits:{tokens:{keys:{[key]:{limit:10,since}}}},subject:{account:key,modelKeyId:other},records:[native]};
|
|
+ assert.equal(token.mayLaunch(a).allow,false);assert.equal(token.mustStop(a).stop,true);
|
|
+ assert.equal(token.report(a)[0].count,10);assert.deepEqual(token.report(a)[0].path,['tokens','keys',key]);
|
|
+ assert.equal(token.mayLaunch({...a,records:[{...native,input:9}]}).allow,true);
|
|
+ assert.deepEqual(daily.report({...a,now:native.at}),[]);
|
|
+});
|
|
+for(const proxy of [false,true])test(`P5 R1 legacy account core lookup stays independent of ${proxy?'unrelated proxy metadata':'missing proxy metadata'}`,async t=>{
|
|
+ const root=mkdtempSync(join(tmpdir(),'p5-legacy-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
+ let lookups=0;
|
|
+ const b={launch:{account:{name:key,credential:'FAKE_TOKEN_NAME'},limits:{tokens:{keys:{[key]:{limit:10,since}}}}},plugins:{'limit-check':['token-limit']}};
|
|
+ const limits=createLimits({store,read:()=>b,log:()=>{},keyForSubject:(...args)=>{lookups++;return proxy?modelUsageKey([{service:'model',instance:'demo/coder',keyId:other,retiring:[]}],new Map(),'demo',args[1]):null;}});
|
|
+ assert.deepEqual(await limits.mayLaunch('demo',{instance:'coder',account:key}),{allow:true});
|
|
+ store.run(`INSERT INTO usage_records(at,business,run,instance,account,turn,source,phase,status,cursor,input,output,cache_write,cache_read)
|
|
+ VALUES (?,'demo','legacy-run','coder',?,'turn','transcript','end','known',?,10,0,0,0)`,native.at,key,JSON.stringify({modelKeyId:other}));
|
|
+ const hit={path:['tokens','keys',key],limit:10,count:10,unknownTurns:0};
|
|
+ assert.deepEqual(await limits.mayLaunch('demo',{instance:'coder',account:key}),{allow:false,check:'token-limit',hit});
|
|
+ assert.deepEqual(await limits.mustStop('demo',{instance:'coder',account:key}),{stop:true,check:'token-limit',hit});
|
|
+ assert.equal(limits.report('demo')[0].count,10);assert.equal(lookups,0);
|
|
+});
|
|
+for(const leaf of [{limit:0,since},quota])test(`P5 R1 empty checks bypass model metadata for ${leaf.reset?'daily model':'legacy hex account'} leaves`,async t=>{
|
|
+ const root=mkdtempSync(join(tmpdir(),'p5-no-checks-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
+ let lookups=0;const limits=createLimits({store,read:()=>({plugins:{'limit-check':[]},launch:{limits:{tokens:{keys:{[key]:leaf}}}}}),log:()=>{},keyForSubject:()=>{lookups++;throw Error('MUST-NOT-LOOK-UP');}});
|
|
+ assert.deepEqual(await limits.mayLaunch('demo',{instance:'coder',account:key}),{allow:true});
|
|
+ assert.deepEqual(await limits.mustStop('demo',{instance:'coder',account:key}),{stop:false});assert.equal(lookups,0);
|
|
+});
|
|
+test('P5 R1 legacy hex account chief resets stay allowed; daily model old/new/removal/reset transitions stay human-only',t=>{
|
|
+ const root=mkdtempSync(join(tmpdir(),'p5-fence-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
+ const rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=join(root,'businesses/demo.json');
|
|
+ doc.launch.account={name:key,credential:'FAKE_TOKEN_NAME'};doc.launch.limits={tokens:{keys:{[key]:{limit:10,since}}}};writeJson(file,doc);
|
|
+ let now='2026-10-26T12:00:00.000Z';const settings=launchSettings({dir:root,rolesDir,system:systemFor(root),now:()=>now});let v=settings.read('demo');
|
|
+ v=settings.set('demo',{revision:v.revision,reset:[['tokens','keys',key]]},'chief');
|
|
+ assert.equal(JSON.parse(readFileSync(file,'utf8')).launch.limits.tokens.keys[key].since,now);
|
|
+ const dailyLeaf={limit:10,reset:'00:00',timeZone:'UTC'};
|
|
+ assert.throws(()=>settings.set('demo',{revision:v.revision,limits:{tokens:{keys:{[key]:dailyLeaf}}}},'chief'),{code:'organization-model-quota-human-only'});
|
|
+ v=settings.set('demo',{revision:v.revision,limits:{tokens:{keys:{[key]:dailyLeaf}}}},'jason');now='2026-10-26T13:00:00.000Z';
|
|
+ for(const args of [{reset:[['tokens','keys',key]]},{limits:{}},{limits:{tokens:{keys:{[key]:{limit:10}}}}}])
|
|
+ assert.throws(()=>settings.set('demo',{revision:v.revision,...args},'chief'),{code:'organization-model-quota-human-only'});
|
|
+ v=settings.set('demo',{revision:v.revision,limits:{tokens:{keys:{[key]:{limit:10}}}}},'jason');
|
|
+ v=settings.set('demo',{revision:v.revision,reset:[['tokens','keys',key]]},'chief');
|
|
+ assert.equal(JSON.parse(readFileSync(file,'utf8')).launch.limits.tokens.keys[key].since,now);
|
|
+});
|
|
+
|
|
+test('P5 R1 daily model matching/report is separate from legacy account and schema refuses partial or invalid daily shapes',async()=>{
|
|
+ const {checkTokenLimits}=await import('../../business/src/launch.mjs');
|
|
+ assert.deepEqual(checkTokenLimits({keys:{[key]:quota}}).keys[key],quota);
|
|
+ const args={limits:{tokens:{keys:{[key]:quota}}},records:[native],subject:{account:key,modelKeyId:other},now:native.at};
|
|
+ assert.deepEqual(token.report(args),[]);
|
|
+ assert.equal(daily.mayLaunch(args).allow,true);assert.equal(daily.report(args)[0].count,0);
|
|
+ const owned={...args,subject:{account:'other-account',modelKeyId:key},records:[{...native,account:'other-account',modelKeyId:key}]};
|
|
+ assert.equal(daily.mayLaunch(owned).allow,false);assert.equal(daily.mustStop(owned).stop,true);assert.equal(daily.report(owned)[0].count,10);
|
|
+ for(const leaf of [{...quota,reset:'25:00'},{...quota,timeZone:'Mars/Phobos'},{limit:10,since,reset:'00:00'},{limit:10,since,timeZone:'UTC'}])
|
|
+ assert.throws(()=>checkTokenLimits({keys:{[key]:leaf}}));
|
|
+});
|
|
diff --git a/packages/bus/tests/p5-key-context.test.mjs b/packages/bus/tests/p5-key-context.test.mjs
|
|
index 8b2ebe0947591589f83d5c89b49bbd8fcf261e10..46b7a87c8e9cbfe4515e35be768f619dffc4a2b6 100644
|
|
--- a/packages/bus/tests/p5-key-context.test.mjs
|
|
+++ b/packages/bus/tests/p5-key-context.test.mjs
|
|
@@ -22,7 +22,7 @@ test('P5 broker preserves original plugin subject and derives internal run conte
|
|
});
|
|
test('P5 configured key quotas never use a supplied safe-looking ID when trusted metadata lookup is missing',async t=>{
|
|
const root=mkdtempSync(join(tmpdir(),'p5-no-bypass-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
- const doc={plugins:{'limit-check':['token-limit']},launch:{limits:{tokens:{keys:{[key]:{limit:10,since:'2026-01-01T00:00:00.000Z'}}}}}};
|
|
+ const doc={plugins:{'limit-check':['daily-quota']},launch:{limits:{tokens:{keys:{[key]:{limit:10,since:'2026-01-01T00:00:00.000Z',reset:'00:00',timeZone:'UTC'}}}}}};
|
|
const limits=createLimits({store,read:()=>doc,log:()=>{},keyForSubject:()=>null});
|
|
assert.deepEqual(await limits.mustStop('demo',{instance:'coder',modelKeyId:key},{run:'forged-run'}),{stop:true,check:null,code:'proxy-key-refused'});
|
|
});
|
|
@@ -38,7 +38,7 @@ test('P5 internal context resolves exactly current or retained generation; wrong
|
|
|
|
test('P5 model-key mustStop without native run context fails closed even when the current key resolves',async t=>{
|
|
const root=mkdtempSync(join(tmpdir(),'p5-missing-context-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
- const doc={plugins:{'limit-check':['token-limit']},launch:{limits:{tokens:{keys:{[key]:{limit:10,since:'2026-01-01T00:00:00.000Z'}}}}}};
|
|
+ const doc={plugins:{'limit-check':['daily-quota']},launch:{limits:{tokens:{keys:{[key]:{limit:10,since:'2026-01-01T00:00:00.000Z',reset:'00:00',timeZone:'UTC'}}}}}};
|
|
const limits=createLimits({store,read:()=>doc,log:()=>{},keyForSubject:()=>key});
|
|
assert.deepEqual(await limits.mustStop('demo',{instance:'coder'}),{stop:true,check:null,code:'proxy-key-refused'});
|
|
});
|
|
diff --git a/packages/bus/tests/p5-telemetry-fixture.mjs b/packages/bus/tests/p5-telemetry-fixture.mjs
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..99390f7d565a7d7db5344b574bc5a8623d5fa073
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/p5-telemetry-fixture.mjs
|
|
@@ -0,0 +1,66 @@
|
|
+// Fresh, made-up instance and local management listener only; no worker/provider.
|
|
+import http from 'node:http';
|
|
+import { createHash, randomBytes } from 'node:crypto';
|
|
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
+import { tmpdir } from 'node:os';
|
|
+import { join } from 'node:path';
|
|
+import { startBroker } from '../src/runtime.mjs';
|
|
+import { Registry } from '../../seat/src/session.mjs';
|
|
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
|
+export async function telemetryFixture(t, { foreign = false, hold = false } = {}) {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'p5-telemetry-')), config = join(root,'config'), data = join(root,'data');
|
|
+ mkdirSync(config,{mode:0o700}); mkdirSync(data,{mode:0o700});
|
|
+ const rolesDir = rolesCopy(config), doc = businessDoc(config,'demo');
|
|
+ doc.plugins = { usage:'cliproxy-queue', harness:['pi'], 'usage-source':['transcript'], 'limit-check':['token-limit'] };
|
|
+ doc.usage = { cliproxyQueue:{pollSeconds:1} };
|
|
+ writeJson(join(config,'businesses/demo.json'),doc);
|
|
+ mkdirSync(join(config,'secrets'),{recursive:true,mode:0o700});
|
|
+ writeFileSync(join(config,'secrets/proxy-management.key'),'p5-r1-made-up-management',{mode:0o600});
|
|
+ new Registry(data);
|
|
+ const tag = createHash('sha256').update(config).digest('hex').slice(0,12);
|
|
+ const own = `msk-${tag}-sentinel-${randomBytes(32).toString('base64url')}`;
|
|
+ const other = `msk-ffffffffffff-sentinel-${randomBytes(32).toString('base64url')}`;
|
|
+ const state = { keys:[foreign?other:own], calls:[], queued:[{api_key:'msk-r1-unknown-fake',source:'p5-r1-upstream-canary',failed:false,tokens:{input_tokens:1,output_tokens:2}}], held:[], hold };
|
|
+ const logs=[], waiters=[];
|
|
+ const stderr = process.stderr.write;
|
|
+ t.mock.method(process.stderr,'write',function(chunk,...args) {
|
|
+ const text=String(chunk);logs.push(text);
|
|
+ for(const waiter of [...waiters]) if(text.includes(waiter.fragment) && logs.length>waiter.from) { waiters.splice(waiters.indexOf(waiter),1);waiter.resolve(); }
|
|
+ return stderr.call(this,chunk,...args);
|
|
+ });
|
|
+ const waitLog=(fragment,from=0)=> {
|
|
+ if(logs.slice(from).some(line=>line.includes(fragment)))return Promise.resolve();
|
|
+ return new Promise(resolve=>waiters.push({fragment,from,resolve}));
|
|
+ };
|
|
+ let queueWaiters=[];
|
|
+ const waitQueue=(count)=> state.calls.filter(p=>p.includes('/usage/queue')).length>=count?Promise.resolve():new Promise(resolve=>queueWaiters.push({count,resolve}));
|
|
+ const answerQueue=res=>{res.end(JSON.stringify(state.queued));state.queued=[];};
|
|
+ const server=http.createServer(async(req,res)=>{
|
|
+ state.calls.push(req.url);
|
|
+ if(req.headers.authorization!=='Bearer p5-r1-made-up-management' && req.url.startsWith('/v8/management/')) {res.writeHead(401);res.end('{}');return;}
|
|
+ if(req.url==='/v8/management/config/access/api-keys') {
|
|
+ if(req.method==='PUT'){let body='';for await(const c of req)body+=c;state.keys=JSON.parse(body);res.end('{"status":"ok"}');}
|
|
+ else res.end(JSON.stringify(state.keys));return;
|
|
+ }
|
|
+ if(req.url==='/v8/management/observability/usage/queue?count=100') {
|
|
+ if(state.hold)state.held.push(res);else answerQueue(res);
|
|
+ const count=state.calls.filter(p=>p.includes('/usage/queue')).length;
|
|
+ for(const waiter of queueWaiters.filter(w=>count>=w.count))waiter.resolve();
|
|
+ queueWaiters=queueWaiters.filter(w=>count<w.count);return;
|
|
+ }
|
|
+ res.writeHead(state.keys.includes(req.headers.authorization?.slice(7))?200:401);res.end('{}');
|
|
+ });
|
|
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
+ let bus=null,startPromise=null,closePromise=null;
|
|
+ const start=()=>startPromise??=(startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
|
+ organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
|
|
+ const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
|
|
+ const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
|
|
+ t.after(async()=>{release();await startPromise?.catch(()=>{});await close();server.closeAllConnections();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
|
+ return {root,data,config,state,logs,own,other,start,waitLog,waitQueue,release,close,
|
|
+ settleInitial:()=>Promise.race([waitLog('proxy-usage-read-failed'),waitLog('proxy.usage.reconcile')]),
|
|
+ get bus(){return bus;},queueCalls:()=>state.calls.filter(p=>p.includes('/usage/queue')).length,
|
|
+ human:()=>bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),
|
|
+ async pollSettledAfterTick(t){const from=logs.length,next=state.calls.filter(p=>p.includes('/usage/queue')).length+1;t.mock.timers.tick(1000);await Promise.race([waitQueue(next),waitLog('proxy-usage-read-failed',from)]);await new Promise(setImmediate);},
|
|
+ };
|
|
+}
|
|
diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs
|
|
index 4ee37c5c6514bdf541b20e7001873fb5d8e878f3..7871fdda595d23c3645b417dbf96783faac9047e 100644
|
|
--- a/packages/business/src/launch.mjs
|
|
+++ b/packages/business/src/launch.mjs
|
|
@@ -1,5 +1,6 @@
|
|
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
|
|
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
|
|
+import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
|
import { refuse } from "./errors.mjs";
|
|
|
|
const name = (value, at) => requireString(value, at, { max: 200 });
|
|
@@ -67,12 +68,16 @@ export function checkTokenLimits(value, where = "launch.limits.tokens") {
|
|
if (Object.hasOwn(value, "models")) {
|
|
out.models = tokenMap(value.models, `${where}.models`, name, (models, at) => tokenMap(models, at, name, tokenLimit));
|
|
}
|
|
- // P5: model key IDs are 12 hex characters (including digit-leading IDs).
|
|
- // A model key may use the daily quota shape; existing account limits retain theirs.
|
|
- if (Object.hasOwn(value, "keys")) out.keys = tokenMap(value.keys, `${where}.keys`,
|
|
- (key, at) => /^[a-f0-9]{12}$/.test(key) ? key : requireId(key, at),
|
|
- (entry, at) => Object.hasOwn(entry ?? {}, "reset") && /^[a-f0-9]{12}$/.test(at.split('.').at(-1))
|
|
- ? dailyQuota(entry, at) : tokenLimit(entry, at));
|
|
+ // P5 adds daily model leaves, including digit-leading IDs; plain keys keep
|
|
+ // their existing account-name schema and cumulative account semantics.
|
|
+ if (Object.hasOwn(value, "keys")) {
|
|
+ requireObject(value.keys, `${where}.keys`);
|
|
+ out.keys = Object.fromEntries(Object.entries(value.keys).map(([key, entry]) => {
|
|
+ const at = `${where}.keys.${key}`;
|
|
+ if (isDailyModelQuota(key, entry)) return [key, dailyQuota(entry, at)];
|
|
+ return [requireId(key, `${where}.keys`), tokenLimit(entry, at)];
|
|
+ }));
|
|
+ }
|
|
// One quota per account; the business read checks each key names launch.account.
|
|
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
|
|
return out;
|
|
diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs
|
|
index 6d358631d81464dcac98ec0db2388f08e9eceb39..2acb92e417f32f9080396b41d1a2f565272a01d4 100644
|
|
--- a/packages/business/src/organization.mjs
|
|
+++ b/packages/business/src/organization.mjs
|
|
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
|
|
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
|
+import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
|
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
|
|
|
export class OrganizationError extends Error {
|
|
@@ -84,7 +85,7 @@ function changes(old, next, path = "") {
|
|
// P5/Q222: every writer protects per-model-key quotas, including removal/reset.
|
|
function modelQuotaFence(old, document, actor) {
|
|
const keys = doc => Object.fromEntries(Object.entries(doc?.launch?.limits?.tokens?.keys ?? {})
|
|
- .filter(([key]) => /^[a-f0-9]{12}$/.test(key)));
|
|
+ .filter(([key, entry]) => isDailyModelQuota(key, entry)));
|
|
if (canonicalJson(keys(old)) === canonicalJson(keys(document))) return;
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
|
|
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
|
diff --git a/packages/plugins/limit-check/daily-quota/index.mjs b/packages/plugins/limit-check/daily-quota/index.mjs
|
|
index 38b445803d13a05803ec91e11556346e868a2437..096aa2ead37a18cfa17b2e2d43e19fb5604350f6 100644
|
|
--- a/packages/plugins/limit-check/daily-quota/index.mjs
|
|
+++ b/packages/plugins/limit-check/daily-quota/index.mjs
|
|
@@ -4,6 +4,7 @@
|
|
// timeZone}. A count is every kind summed over the account's known records since the later of the
|
|
// day's reset and the leaf's `since`; an unknown turn adds nothing and is counted on its own.
|
|
// It copies token-limit's tally helpers rather than importing a sibling check, so each loads alone.
|
|
+import { isDailyModelQuota } from '../../src/model-quota.mjs';
|
|
export const interfaceVersion = 2;
|
|
|
|
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
|
@@ -63,7 +64,7 @@ function windowStart(entry, now) {
|
|
function leaves(limits) {
|
|
const daily = limits?.tokens?.daily ?? {};
|
|
const entries = [...Object.entries(daily).map(([account, entry]) => ({ path: ['tokens', 'daily', account], entry, match: x => x.account === account })),
|
|
- ...Object.entries(limits?.tokens?.keys ?? {}).filter(([key, entry]) => /^[a-f0-9]{12}$/.test(key) && Object.hasOwn(entry, 'reset'))
|
|
+ ...Object.entries(limits?.tokens?.keys ?? {}).filter(([key, entry]) => isDailyModelQuota(key, entry))
|
|
.map(([key, entry]) => ({ path: ['tokens', 'keys', key], entry, match: x => x.modelKeyId === key }))];
|
|
return entries.map(({ path, entry, match }) => {
|
|
if (!Number.isSafeInteger(entry?.limit) || entry.limit < 0 || typeof entry.reset !== 'string' || !RESET.test(entry.reset) ||
|
|
diff --git a/packages/plugins/limit-check/token-limit/index.mjs b/packages/plugins/limit-check/token-limit/index.mjs
|
|
index 8f26fcb9c7cbcae4c6cf953a6827af53590771be..b20cea356ca7081a6cf571949a0f6aa31dfec89d 100644
|
|
--- a/packages/plugins/limit-check/token-limit/index.mjs
|
|
+++ b/packages/plugins/limit-check/token-limit/index.mjs
|
|
@@ -3,6 +3,7 @@
|
|
// launch limits and its per-turn usage records, and nothing here reads a file
|
|
// or a store. A count is every kind summed over the known records since the
|
|
// limit's `since`; an unknown turn adds nothing and is counted on its own.
|
|
+import { isDailyModelQuota } from '../../src/model-quota.mjs';
|
|
export const interfaceVersion = 2;
|
|
|
|
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
|
@@ -31,8 +32,8 @@ function leaves(limits) {
|
|
// A key is the record's account. Accounts are null until row 126 names them,
|
|
// so a key limit matches nothing until then.
|
|
for (const [k, entry] of Object.entries(tokens.keys ?? {}))
|
|
- if (!Object.hasOwn(entry, 'reset')) out.push({ path: ['tokens', 'keys', k], entry,
|
|
- match: x => /^[a-f0-9]{12}$/.test(k) ? x.modelKeyId === k : x.account === k, key: true });
|
|
+ if (!isDailyModelQuota(k, entry)) out.push({ path: ['tokens', 'keys', k], entry,
|
|
+ match: x => x.account === k, key: true });
|
|
return out;
|
|
}
|
|
|
|
@@ -73,7 +74,7 @@ export function mustStop(args) {
|
|
// while no record names an account (lead decision 150, Q1).
|
|
export function report({ limits, records }) {
|
|
if (!Array.isArray(records)) refuse('bad-records');
|
|
- const named = records.some(r => r.account != null || r.modelKeyId != null);
|
|
+ const named = records.some(r => r.account != null);
|
|
return leaves(limits).map(leaf => {
|
|
const { path, entry: { limit, since } } = leaf;
|
|
if (leaf.key && !named) return { path, limit, since, notEnforced: 'no records name a key' };
|
|
diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs
|
|
index 04731ee91b04329a3956fd751f4d7fb215f845c3..5634a90664ea98022074cd9fff9bbfbd279692e8 100644
|
|
--- a/packages/plugins/src/loader.mjs
|
|
+++ b/packages/plugins/src/loader.mjs
|
|
@@ -162,3 +162,6 @@ export function createLoader({ log, barrier = () => {} }) {
|
|
|
|
return { load, loaded, modules, snapshot };
|
|
}
|
|
+
|
|
+// Shared pure plug-in setting classification; no check implementation is loaded.
|
|
+export { isDailyModelQuota } from "./model-quota.mjs";
|
|
diff --git a/packages/plugins/src/model-quota.mjs b/packages/plugins/src/model-quota.mjs
|
|
new file mode 100644
|
|
index 0000000000000000000000000000000000000000..1350eb56b5947e759ec8d32f0c429813c352f2a9
|
|
--- /dev/null
|
|
+++ b/packages/plugins/src/model-quota.mjs
|
|
@@ -0,0 +1,17 @@
|
|
+// The new P5 namespace is the validated DAILY leaf, never a twelve-hex name
|
|
+// alone. Plain {limit,since} keys remain legacy account limits.
|
|
+const ID = /^[a-f0-9]{12}$/;
|
|
+const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
+const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
+const SINCE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
+export function isDailyModelQuota(key, value) {
|
|
+ if (typeof key !== 'string' || !ID.test(key) || !value || typeof value !== 'object' || Array.isArray(value)
|
|
+ || Object.keys(value).some(k => !['limit','since','reset','timeZone'].includes(k))
|
|
+ || !Number.isSafeInteger(value.limit) || value.limit < 0
|
|
+ || typeof value.since !== 'string' || !SINCE.test(value.since) || Number.isNaN(Date.parse(value.since))
|
|
+ || new Date(value.since).toISOString() !== value.since
|
|
+ || typeof value.reset !== 'string' || !RESET.test(value.reset)
|
|
+ || typeof value.timeZone !== 'string' || !ZONE.test(value.timeZone)) return false;
|
|
+ try { new Intl.DateTimeFormat('en-US', { timeZone:value.timeZone }); return true; }
|
|
+ catch { return false; }
|
|
+}
|
|
diff --git a/packages/plugins/tests/token-limit.test.mjs b/packages/plugins/tests/token-limit.test.mjs
|
|
index d8f8d3db7928f034d7126d479f4255637d0f8646..2c08be007564e5d4b223a7f93bcf4b36535fe739 100644
|
|
--- a/packages/plugins/tests/token-limit.test.mjs
|
|
+++ b/packages/plugins/tests/token-limit.test.mjs
|
|
@@ -150,11 +150,11 @@ test("REQ-LIMIT-3 token-limit ignores tokens.daily, which the daily-quota check
|
|
assert.deepEqual(check.report(a), []);
|
|
});
|
|
|
|
-test('P5 a model key ID limit counts stack records by key ID without an account', () => {
|
|
- const keyId = '012345abcdef';
|
|
+test('P5 plain twelve-hex keys keep legacy account matching rather than cumulative model matching', () => {
|
|
+ const keyId = 'abcdef012345';
|
|
const args = { business: 'demo', limits: { tokens: { keys: { [keyId]: { limit: 5, since: '2026-10-18T00:00:00.000Z' } } } },
|
|
- subject: { instance: 'coder', modelKeyId: keyId, account: null }, records: [{ at: '2026-10-18T01:00:00.000Z',
|
|
- run: 'run', turn: 'turn', modelKeyId: keyId, account: null, status: 'known', input: 5, output: 0, cacheWrite: 0, cacheRead: 0 }] };
|
|
+ subject: { instance: 'coder', modelKeyId: '012345abcdef', account: keyId }, records: [{ at: '2026-10-18T01:00:00.000Z',
|
|
+ run: 'run', turn: 'turn', modelKeyId: '012345abcdef', account: keyId, status: 'known', input: 5, output: 0, cacheWrite: 0, cacheRead: 0 }] };
|
|
assert.equal(check.mayLaunch(args).allow, false);
|
|
assert.equal(check.report(args)[0].count, 5);
|
|
});
|
|
diff --git a/packages/plugins/usage/cliproxy-queue/README.md b/packages/plugins/usage/cliproxy-queue/README.md
|
|
index 98aa6e8a2757e170dc2a27752e0b474a9fb3fab3..7e1b0c1996b536deb0013d4d58c8ee1d2697ad8e 100644
|
|
--- a/packages/plugins/usage/cliproxy-queue/README.md
|
|
+++ b/packages/plugins/usage/cliproxy-queue/README.md
|
|
@@ -35,8 +35,9 @@ usage cursor, preserved at turn begin and end. The Console exposes `byKey`.
|
|
A business can set a daily per-key quota at
|
|
`launch.limits.tokens.keys.<12-hex-key-id>` with `{limit, since, reset, timeZone}`;
|
|
the human writer stamps `since`. Enable `limit-check: ["daily-quota"]` to apply
|
|
-that quota. Cumulative model-key limits retain `{limit,since}` and use
|
|
-`token-limit`. Omitting a limit means no quota. Existing named account keys and
|
|
+that quota. Plain `{limit,since}` keys leaves remain cumulative legacy account limits, even
|
|
+when the name is twelve hex characters. They use `token-limit` and never resolve
|
|
+model metadata. Omitting a limit means no quota. Existing named account keys and
|
|
`tokens.daily.<account>` retain their existing behavior. Model-key limits and
|
|
resets are human-only through every organization writer. Missing, ambiguous or
|
|
foreign run-key metadata fails closed with `proxy-key-refused`; a retiring run
|