Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/TRANSCRIPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,7 @@ The file is parsed with `withTurns: true` by the provider's parser
(`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows
carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`,
`models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the
same per-model usage rows `aggregate()` uses (the header used to render a
hardcoded `$0.00`; the comment at the site records why).
same per-model usage rows `aggregate()` uses.

### 4.3 Mask, then truncate — both marked, differently

Expand Down Expand Up @@ -363,6 +362,11 @@ was wrong before, for the curious.
- **Session expander fields shipped but unrendered.** The per-session fields
§6.1's expander now renders (classification `basis` + confidence, the
token split, flags) once travelled on the wire and rendered nowhere.
- **Transcript header once showed a hardcoded `$0.00`.** `readSession`'s
assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the
truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole
subject is cost. `meta.cost` is now priced via `sessionCost()` from the
same per-model usage rows `aggregate()` uses (`usage-index.mjs:1538-1542`).
- **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing
defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A.

Expand Down
8 changes: 7 additions & 1 deletion src/lib/dashboard-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,13 @@ export function startDashboard({
const usageApi = usage || lazyUsage();
// Injectable like `usage`: tests must never spawn a real codex or read the
// real ~/.config through this route. Lazy for the same reason lazyUsage is.
const provideLimits = limits || (async () => (await import('./quota.mjs')).readLimits());
// enabledHosts drives quota.mjs's F-10 labeling (any OTHER enabled host with
// no sanctioned quota channel) from the same kit.json read used elsewhere in
// this file (see loadKitConfig() below) — never a second, ad hoc source.
const provideLimits = limits || (async () => {
const { readLimits } = await import('./quota.mjs');
return readLimits({ enabledHosts: loadKitConfig().integrations.hosts });
});
const provideLive = typeof live === 'function' ? live : live ? async () => live : lazyLive(liveOptions);
// Same injection contract as `live`: a function is called to produce the
// collector, a value is reused verbatim (tests hand over a fake so the route
Expand Down
40 changes: 36 additions & 4 deletions src/lib/quota.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
// server-enforced), no Keychain/credentials reads, no chatgpt.com backend
// endpoints (private; requires bearer-token handling ak must not do).
//
// Labeling policy (F-10): any OTHER registry-managed host (adapters/
// registries.mjs) that the caller reports enabled gets an explicit
// `{ supported: false }` entry instead of being silently absent — this adds
// no channel and performs no probe, it only says "no quota surface exists
// for this host" so a user can tell that apart from "broken".
//
// Field-name trap, load-bearing: Codex's `primary`/`secondary` window fields do
// NOT reliably mean "5-hour"/"weekly" — a live prolite account answered with
// `primary.windowDurationMins = 10080` (the weekly) and `secondary = null`.
Expand All @@ -26,6 +32,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { configDir } from './paths.mjs';
import { managedHostIds } from './adapters/registries.mjs';

export const claudeLimitsFile = () => path.join(configDir(), 'claude-rate-limits.json');
export const codexLimitsFile = () => path.join(configDir(), 'codex-rate-limits.json');
Expand Down Expand Up @@ -239,20 +246,45 @@ export async function collectCodexLimits({
return fresh;
}

// ── Unsupported hosts (F-10 labeling policy) ────────────────────────────────

/**
* Every OTHER registry-managed host (session-driving, per adapters/
* registries.mjs `managedHostIds()`) beyond the two ADR-0010-sanctioned
* channels, labeled `{ supported: false }` instead of left absent — IF the
* caller reports it enabled. A host the caller does not report as enabled is
* omitted entirely: a user who never turned it on should see nothing, not a
* label. Adds no channel; performs no probe. Pure.
*
* @param {{ enabledHosts?: Record<string, boolean> }} [o]
*/
export function unsupportedQuotaHosts({ enabledHosts = {} } = {}) {
return managedHostIds()
.filter((id) => id !== 'claude' && id !== 'codex' && enabledHosts[id] === true)
.map((provider) => ({ provider, supported: false, reason: 'no quota surface for this host' }));
}

// ── Combined read (the /api/limits payload) ─────────────────────────────────

/**
* Both providers, plus the freshness contract the UI renders: `fetchedAt` on
* each side and `generatedAt` overall. Claude is a pure file read (push
* model); Codex may spawn one vendor subprocess, TTL-bounded.
* model); Codex may spawn one vendor subprocess, TTL-bounded. `others` lists
* any additional enabled host with no sanctioned quota channel (F-10);
* omitting `enabledHosts` (the default) leaves it empty, so claude/codex
* output is unchanged unless a caller opts in.
*
* @param {{ now?: number, claudeFile?: string, codexCacheFile?: string, ttlMs?: number,
* timeoutMs?: number, spawnImpl?: any, bin?: string }} [o]
* timeoutMs?: number, spawnImpl?: any, bin?: string,
* enabledHosts?: Record<string, boolean> }} [o]
*/
export async function readLimits({ now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin } = {}) {
export async function readLimits({
now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin, enabledHosts,
} = {}) {
const claude = readClaudeLimits({ file: claudeFile ?? claudeLimitsFile() });
const codex = await collectCodexLimits({
ttlMs, cacheFile: codexCacheFile ?? codexLimitsFile(), now, timeoutMs, spawnImpl, bin,
});
return { generatedAt: new Date(now).toISOString(), claude, codex };
const others = unsupportedQuotaHosts({ enabledHosts });
return { generatedAt: new Date(now).toISOString(), claude, codex, others };
}
58 changes: 57 additions & 1 deletion tests/kit/quota.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import path from 'node:path';
import { EventEmitter } from 'node:events';
import {
windowLabel, normalizeClaudeLimits, normalizeCodexLimits, readClaudeLimits,
collectCodexLimits, CODEX_TTL_MS,
collectCodexLimits, CODEX_TTL_MS, unsupportedQuotaHosts, readLimits,
} from '../../src/lib/quota.mjs';

const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-quota-'));
Expand Down Expand Up @@ -192,3 +192,59 @@ test('collectCodexLimits returns null when there has never been an answer', asyn
const out = await collectCodexLimits({ cacheFile: path.join(tmp(), 'none.json'), spawnImpl: failSpawn });
assert.equal(out, null);
});

// ── unsupportedQuotaHosts — F-10: label absence instead of leaving it silent ─
//
// ADR-0010 sanctions exactly two channels (claude's statusline tee, codex's
// app-server). This does not add a third — it never probes anything — it only
// says so, for any OTHER registry-managed host the caller reports enabled.

test('unsupportedQuotaHosts labels an enabled managed host with no channel', () => {
const out = unsupportedQuotaHosts({ enabledHosts: { opencode: true } });
assert.deepEqual(out, [
{ provider: 'opencode', supported: false, reason: 'no quota surface for this host' },
]);
});

test('unsupportedQuotaHosts omits a host the caller has not enabled', () => {
assert.deepEqual(unsupportedQuotaHosts(), []);
assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: {} }), []);
assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: { opencode: false } }), []);
});

test('unsupportedQuotaHosts never labels the two sanctioned channels', () => {
const out = unsupportedQuotaHosts({ enabledHosts: { claude: true, codex: true, opencode: true } });
assert.deepEqual(out.map((h) => h.provider), ['opencode']);
});

// ── readLimits — the combined /api/limits payload ───────────────────────────

test('readLimits: claude/codex outputs are byte-identical whether or not others are reported', async () => {
const dir = tmp();
const claudeFile = path.join(dir, 'claude-rate-limits.json');
fs.writeFileSync(claudeFile, JSON.stringify(CLAUDE_TEE));
const codexCacheFile = path.join(dir, 'codex-rate-limits.json');

const withoutOthers = await readLimits({
now: 1000, claudeFile, codexCacheFile, spawnImpl: fakeSpawn(CODEX_RESP),
});
const withOthers = await readLimits({
now: 1000, claudeFile, codexCacheFile: path.join(dir, 'codex-rate-limits-2.json'),
spawnImpl: fakeSpawn(CODEX_RESP), enabledHosts: { claude: true, codex: true, opencode: true },
});

assert.deepEqual(withOthers.claude, withoutOthers.claude);
assert.deepEqual(withOthers.codex, withoutOthers.codex);
assert.deepEqual(withoutOthers.others, []);
assert.deepEqual(withOthers.others, [
{ provider: 'opencode', supported: false, reason: 'no quota surface for this host' },
]);
});

test('readLimits defaults to no unsupported-host labels when enabledHosts is not passed', async () => {
const out = await readLimits({
now: 1000, claudeFile: path.join(tmp(), 'absent.json'),
codexCacheFile: path.join(tmp(), 'codex.json'), spawnImpl: failSpawn,
});
assert.deepEqual(out.others, []);
});
Loading