From fa2d1af130035499d8fb5338c5b55f1dce1f5f8d Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Wed, 27 May 2026 11:24:46 +1000 Subject: [PATCH] feat+test: F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## F4 — bin/olp.mjs + olp-plugin/index.js migrate to quota_v2 Codex post-v0.5.0 review Q4: both `olp usage` CLI and `/olp usage` plugin handler read legacy `body.quota` shape, which never has `percent_used` or meaningful `available` data → display always fell through to "no quota api" even when anthropic live quota was visible on the dashboard (D81/D82). Authority: codex review Q4 (PR #58); ADR 0008 Amendment 2 (quota_v2 shape: { provider, status, utilization, reset, representative_claim, fallback_percentage, overage, failure?, failure_kind?, backoff_until? }). ### bin/olp.mjs cmdUsage - When `body.quota_v2` present (non-empty array, server v0.5.0+): render per-provider rows with status badge (live/stale/unreachable/unavailable), 5h + 7d utilization % (color-coded: green <50% / yellow 50-80% / red ≥80%), reset countdowns, binding claim, ⚠ stale / ❌ unreachable annotations. - When `body.quota_v2` absent: fall through to existing legacy `body.quota` rendering (preserves backwards compat with pre-v0.5.0 servers). - Added `formatResetCountdown(epochSeconds)` — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from dashboard.html D82. Exported for tests. Kept in bin/olp.mjs (not lib/) per spec guidance. - Added `formatAgo(diffMs)` — small helper for stale-row age display. ### olp-plugin/index.js fmtUsage() - Same quota_v2 migration. Plain-text output (no ANSI), one line per provider. Legacy body.quota fallback preserved. - Added `pluginFormatResetCountdown(epochSeconds)` — intentionally duplicated from bin/olp.mjs (olp-plugin ships as a separate package and must not import from bin/). Exported for tests. - Also fixed fmtUsage to read `w.request_count` (dashboard-data shape) in addition to legacy `w.requests` (OCP-era shape) for completeness. ### Backwards compat - Pre-v0.5.0 server returns body.quota only → both surfaces use legacy display (unchanged behaviour). - v0.5.0+ server returns both quota and quota_v2 → both surfaces prefer quota_v2. - Legacy code paths kept (5-10 lines each, not deleted). ## v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — CLOSED The dedicated test was already shipped at D56 (test-features.mjs line 6255: 'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'). This commit closes the roadmap entry with a date stamp and PR reference. Authority: docs/v1x-roadmap.md § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)". ## Tests Suite 40 (9 new tests — 40a through 40i): 40a — cmdUsage parses quota_v2 live rows (mock server) 40b — cmdUsage falls back to legacy body.quota when quota_v2 absent 40c — olp-plugin fmtUsage parses quota_v2 live row 40d — olp-plugin fmtUsage falls back to legacy body.quota 40e — formatResetCountdown covers all 5 time ranges 40f — pluginFormatResetCountdown covers past/<1h/<24h/<7d/≥7d 40g — cmdUsage renders quota_v2 stale row with ⚠ stale note 40h — cmdUsage renders quota_v2 unreachable row with ❌ indicator 40i — cmdUsage renders quota_v2 unavailable rows correctly 759 → 768 tests, 0 fail. Co-authored-by: dtzp555 Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 21 ++- bin/olp.mjs | 87 ++++++++++- docs/v1x-roadmap.md | 5 +- olp-plugin/index.js | 83 +++++++++- test-features.mjs | 363 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 551 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f548e2..92964f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,26 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## Unreleased -(empty — Phase 6 entries land here once Phase 6 opens) +### F4 — `bin/olp.mjs` + `olp-plugin/index.js` migration to `quota_v2` shape + +**Codex post-v0.5.0 review Q4.** Both CLI surfaces (`olp usage` and `/olp usage`) previously fell through to "no quota api" for every provider because they read the legacy `body.quota` shape, which never carries `percent_used` or meaningful `available` data. Now that the server (v0.5.0+) emits `body.quota_v2` per ADR 0008 Amendment 2, both surfaces prefer `quota_v2` and fall back to legacy `quota` on older servers. + +- **`bin/olp.mjs cmdUsage`**: when `body.quota_v2` is present (non-empty array), renders per-provider rows with status (`live` / `stale` / `unreachable` / `unavailable`), 5h and 7d utilization percentages with color-coding (green < 50% / yellow 50–80% / red ≥ 80%), reset countdowns, binding claim, and ⚠ stale / ❌ unreachable annotations. Legacy `body.quota` path preserved as fallback for pre-v0.5.0 servers. `formatResetCountdown(epochSeconds)` added — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from `dashboard.html` D82, kept in-file (no shared lib). + +- **`olp-plugin/index.js fmtUsage()`**: same migration — `quota_v2` rows render as one-line plain text per provider (no ANSI; Telegram/Discord safe). `pluginFormatResetCountdown(epochSeconds)` added; intentionally duplicated (plugin ships as a separate package). Legacy `body.quota` fallback preserved. + +### v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — ✅ CLOSED + +The dedicated AUTH_MISSING engine test (asserting `fallbackDetail[0].trigger_type === 'auth_missing'`) was already shipped at D56 (`test-features.mjs` line 6255). This item closes the roadmap entry with a date stamp and PR reference per the tracker convention. No code changes — documentation only. + +### Tests + +- Suite 40 (9 new tests): `40a`–`40i` covering `cmdUsage` quota_v2 live/stale/unreachable/unavailable parse, legacy fallback, `pluginFormatResetCountdown` and `formatResetCountdown` 5-range coverage, olp-plugin `fmtUsage` quota_v2 + legacy paths. 759 → 768 tests, 0 fail. + +### Authority + +- F4: codex post-v0.5.0 review Q4 (PR #58 review); ADR 0008 Amendment 2 (quota_v2 shape). +- #7: `docs/v1x-roadmap.md` § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)". ## v0.5.1 — 2026-05-27 diff --git a/bin/olp.mjs b/bin/olp.mjs index b468202..88df60a 100755 --- a/bin/olp.mjs +++ b/bin/olp.mjs @@ -226,6 +226,53 @@ function formatMs(ms) { return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`; } +/** formatAgo(diffMs) — "N min ago" / "Nh ago" from a millisecond diff. */ +function formatAgo(diffMs) { + if (typeof diffMs !== 'number' || diffMs < 0) return 'just now'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + return `${Math.floor(min / 60)}h ago`; +} + +/** + * formatResetCountdown(epochSeconds) → human-readable reset countdown string. + * + * Mirrors dashboard.html formatResetCountdown(). Five ranges: + * past / < 1h / < 24h / < 7d / ≥ 7d + * + * Authority: ADR 0008 Amendment 2 (quota_v2 shape); ported from + * dashboard.html (D82). No external deps. Pure formatter. + * + * @param {number|null} epochSeconds — Unix epoch seconds for reset time + * @returns {string} + */ +export function formatResetCountdown(epochSeconds) { + if (epochSeconds == null) return '—'; + const nowMs = Date.now(); + const targetMs = epochSeconds * 1000; + const diffMs = targetMs - nowMs; + if (diffMs <= 0) return 'resetting now'; + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHr / 24); + if (diffMin < 60) return `resets in ${diffMin}m`; + if (diffHr < 24) { + const remMin = diffMin - diffHr * 60; + if (remMin === 0) return `resets in ${diffHr}h`; + return `resets in ${diffHr}h ${remMin}m`; + } + const target = new Date(targetMs); + const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + if (diffDay < 7) { + const dayStr = target.toLocaleString('en-US', { weekday: 'short' }); + return `resets ${dayStr} ${timeStr}`; + } + const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' }); + return `resets ${dateStr} ${timeStr}`; +} + // ── Subcommand: status ──────────────────────────────────────────────────── async function cmdStatus(flags, io) { @@ -329,7 +376,45 @@ async function cmdUsage(flags, io) { } else { io.log(' (no 24h usage data — server may not have processed any requests yet)'); } - if (Array.isArray(body.quota) && body.quota.length > 0) { + // F4 (v0.5.1 codex post-release review Q4): prefer quota_v2 when present + // (server v0.5.0+), fall back to legacy quota array on older servers. + // Authority: ADR 0008 Amendment 2 (quota_v2 shape). + if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) { + io.log(''); + io.log(colorize('Per-provider quota (live)', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const p of body.quota_v2) { + const label = String(p.provider ?? '?').toUpperCase().padEnd(12); + const status = p.status ?? 'unavailable'; + if (status === 'unavailable') { + io.log(` ${colorize(label, ANSI.gray, io.useColor)} unavailable ${p.reason ?? 'no public quota api'}`); + } else if (status === 'unreachable') { + const fk = p.failure?.kind ?? 'unknown'; + const fm = p.failure?.message ?? 'probe failed'; + io.log(` ${colorize(label, ANSI.red, io.useColor)} ❌ no cached data — failure: ${fk} (${fm})`); + } else { + // live or stale + const staleWarn = status === 'stale' + ? colorize(` ⚠ stale${p.last_fresh_at ? ` (${formatAgo(Date.now() - p.last_fresh_at)})` : ''} failure: ${p.failure?.kind ?? 'unknown'}`, ANSI.yellow, io.useColor) + : ''; + const util = p.utilization ?? {}; + const reset = p.reset ?? {}; + const parts = []; + for (const window of ['5h', '7d']) { + const frac = util[window]; + const resetEpoch = reset[window]; + if (frac != null) { + const pct = `${Math.round(frac * 100)}%`; + const rst = formatResetCountdown(resetEpoch); + parts.push(`${window}: ${colorize(pct, frac >= 0.8 ? ANSI.red : frac >= 0.5 ? ANSI.yellow : ANSI.green, io.useColor)} (${rst})`); + } + } + const binding = p.representative_claim ? ` binding: ${p.representative_claim.replace('_', '-')}` : ''; + io.log(` ${colorize(label, ANSI.bold, io.useColor)} ${colorize(status, status === 'live' ? ANSI.green : ANSI.yellow, io.useColor).padEnd(6)} ${parts.join(' ')}${binding}${staleWarn}`); + } + } + } else if (Array.isArray(body.quota) && body.quota.length > 0) { + // Legacy fallback for pre-v0.5.0 servers io.log(''); io.log(colorize('Per-provider quota', ANSI.bold, io.useColor)); io.log('─'.repeat(60)); diff --git a/docs/v1x-roadmap.md b/docs/v1x-roadmap.md index 63dcb3b..e5f7c5a 100644 --- a/docs/v1x-roadmap.md +++ b/docs/v1x-roadmap.md @@ -8,7 +8,7 @@ 3. **Where** does the work live in the tree today (file + anchor). 4. **When** does it need to land (trigger: load profile, security event, governance amendment). -**Reading order for a v1.x sprint kickoff.** As of 2026-05-26, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4 and #7 (closed in D56), and #8 (dashboard enrichment, D82 Phase 5) are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired. +**Reading order for a v1.x sprint kickoff.** As of 2026-05-27, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4, #7, and #8 are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired. --- @@ -107,8 +107,9 @@ - `lib/audit-query.mjs` — current `aggregateRequests` is wall-clock-window; needs session-window variant - **Trigger to start.** ANY of: (a) Anthropic publishes a documented `claude usage` CLI or `api.anthropic.com/v1/usage` endpoint, (b) maintainer hits real "I want to see quota right now" pain often enough to design without per-provider truth (audit-derived only), (c) Phase 5 multi-tenant adds per-key spend limits and the dashboard needs to surface those. -## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up) +## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up) — ✅ **CLOSED (D56, 2026-05-27)** +- **Status.** Closed. Test shipped at D56 (PR `f4-cli-plugin-quota-v2-plus-auth-missing-test`, 2026-05-27). Test: `test-features.mjs` line 6255 — `'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'`. Asserts: `result.fallbackDetail[0].code === 'AUTH_MISSING'`, `result.fallbackDetail[0].trigger_type === 'auth_missing'`, `result.fallbackHops === 0` (no advance). The test was already present in the file before this PR closed the roadmap entry. - **What.** Dedicated test in `test-features.mjs` Suite D40 that asserts the `fallbackDetail` tuple records the AUTH_MISSING path with `trigger_type: 'auth_missing'`. D40 reviewer flagged this as the last gap in the engine-path matrix; code is structurally correct, just lacks an explicit pin. - **Why deferred.** Low priority — the AUTH_MISSING early-return branch has the tuple push BEFORE it (verified in D40 reviewer pass), so coverage is implicit via the other engine-path tests. A 3-line dedicated test would make the pin explicit. - **Design.** No ADR needed. ~5-line test addition. diff --git a/olp-plugin/index.js b/olp-plugin/index.js index ec81185..5300593 100644 --- a/olp-plugin/index.js +++ b/olp-plugin/index.js @@ -169,26 +169,101 @@ export function fmtHealth(body) { return out; } +/** + * formatResetCountdown(epochSeconds) → human-readable reset countdown. + * + * Mirrors bin/olp.mjs + dashboard.html versions. Five ranges: + * past / < 1h / < 24h / < 7d / ≥ 7d + * + * Authority: ADR 0008 Amendment 2 (quota_v2 shape), ported from dashboard.html (D82). + * No external deps. Duplicated here intentionally (olp-plugin ships separately). + */ +export function pluginFormatResetCountdown(epochSeconds) { + if (epochSeconds == null) return "—"; + const nowMs = Date.now(); + const targetMs = epochSeconds * 1000; + const diffMs = targetMs - nowMs; + if (diffMs <= 0) return "resetting now"; + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHr / 24); + if (diffMin < 60) return `resets in ${diffMin}m`; + if (diffHr < 24) { + const remMin = diffMin - diffHr * 60; + if (remMin === 0) return `resets in ${diffHr}h`; + return `resets in ${diffHr}h ${remMin}m`; + } + const target = new Date(targetMs); + const timeStr = target.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); + if (diffDay < 7) { + const dayStr = target.toLocaleString("en-US", { weekday: "short" }); + return `resets ${dayStr} ${timeStr}`; + } + const dateStr = target.toLocaleString("en-US", { month: "short", day: "numeric" }); + return `resets ${dateStr} ${timeStr}`; +} + export function fmtUsage(body) { let out = "OLP usage (24h)\n"; out += "─────────────────────────────\n"; const w = body.window_24h ?? body.usage_24h ?? {}; - if (w.requests !== undefined) { + if (w.request_count !== undefined) { + out += `Requests: ${w.request_count}\n`; + const c = body.cache_hit_24h ?? {}; + if (typeof c.hit_rate === "number") { + out += `Cache hit: ${(c.hit_rate * 100).toFixed(1)}%\n`; + } + } else if (w.requests !== undefined) { out += `Requests: ${w.requests}\n`; out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`; out += `Fallbacks: ${w.fallbacks ?? "?"}\n`; } else if (typeof body.cache_hit_24h === "number") { - // Dashboard-data shape: cache_hit_24h is a rate ∈ [0,1] + // Legacy: cache_hit_24h as a bare number out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`; } - if (Array.isArray(body.quota) && body.quota.length > 0) { + + // F4: prefer quota_v2 when present (server v0.5.0+), fall back to legacy quota. + // Authority: ADR 0008 Amendment 2 (quota_v2 shape). + if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) { + out += `\nPer-provider quota (live):\n`; + for (const p of body.quota_v2) { + const name = String(p.provider ?? "?").toUpperCase().padEnd(10); + const status = p.status ?? "unavailable"; + if (status === "unavailable") { + out += ` ${name} unavailable ${p.reason ?? "no public quota api"}\n`; + } else if (status === "unreachable") { + const fk = p.failure?.kind ?? "unknown"; + out += ` ${name} no cached data — failure: ${fk}\n`; + } else { + // live or stale + const util = p.utilization ?? {}; + const reset = p.reset ?? {}; + const parts = []; + for (const window of ["5h", "7d"]) { + const frac = util[window]; + const resetEpoch = reset[window]; + if (frac != null) { + const pct = `${Math.round(frac * 100)}%`; + const rst = pluginFormatResetCountdown(resetEpoch); + parts.push(`${window}: ${pct} (${rst})`); + } + } + const staleNote = status === "stale" + ? ` ⚠ stale (${p.failure?.kind ?? "unknown"})` + : ""; + out += ` ${name} ${status.padEnd(6)} ${parts.join(" ")}${staleNote}\n`; + } + } + } else if (Array.isArray(body.quota) && body.quota.length > 0) { + // Legacy fallback for pre-v0.5.0 servers out += `\nPer-provider quota:\n`; for (const q of body.quota) { const pct = typeof q.percent_used === "number" ? q.percent_used : null; const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api"; - out += ` ${String(q.name ?? "?").padEnd(10)}${bar0}\n`; + out += ` ${String(q.provider ?? q.name ?? "?").padEnd(10)}${bar0}\n`; } } + if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) { out += `\nTop fallback chains (24h):\n`; for (const f of body.top_fallback_chains_24h.slice(0, 5)) { diff --git a/test-features.mjs b/test-features.mjs index 1e579d9..147f973 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -16900,3 +16900,366 @@ describe('Suite 39 — D83 dashboard rendering smoke tests (Phase 5, ADR 0012 D8 '39h: dashboard HTML must contain legacy quota fallback path (graceful-degradation proof)'); }); }); + +// ── Suite 40: F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin ─ +// +// Codex post-v0.5.0 review Q4: bin/olp.mjs cmdUsage and olp-plugin/index.js +// fmtUsage both read legacy body.quota (never has percent_used or available data), +// so the display always fell through to "no quota api" even when anthropic's live +// quota was visible on the dashboard. F4 adds consumption of body.quota_v2 (server +// v0.5.0+ shape per ADR 0008 Amendment 2) with graceful fallback to body.quota on +// older servers. +// +// v1.x roadmap #7 was already closed at D56 (test at line 6255); this suite +// references that test by description. The roadmap entry closure (marking ✅ CLOSED) +// happens in docs/v1x-roadmap.md (committed alongside these tests). +// +// Tests: +// 40a — cmdUsage renders quota_v2 live rows (mock server) +// 40b — cmdUsage falls back to legacy body.quota when quota_v2 absent +// 40c — olp-plugin fmtUsage parses quota_v2 live row +// 40d — olp-plugin fmtUsage falls back to legacy body.quota when quota_v2 absent +// 40e — formatResetCountdown past / <1h / <24h / <7d / ≥7d ranges +// 40f — olp-plugin pluginFormatResetCountdown parallel coverage +// 40g — cmdUsage renders quota_v2 stale row with ⚠ stale note +// 40h — cmdUsage renders quota_v2 unreachable row with ❌ indicator +// 40i — cmdUsage renders quota_v2 unavailable row with reason + +import { runCli as runOlpCli40, formatResetCountdown as olpFormatResetCountdown } from './bin/olp.mjs'; +import { fmtUsage as plugFmtUsage40, pluginFormatResetCountdown } from './olp-plugin/index.js'; + +// ── Helper: tiny HTTP server that responds to one request with a static JSON body ── +import { createServer as createHttpServerS40 } from 'node:http'; + +function makeJsonServer40(responseBody) { + return new Promise((resolve, reject) => { + const srv = createHttpServerS40((req, res) => { + const body = typeof responseBody === 'function' ? responseBody(req) : responseBody; + const raw = JSON.stringify(body); + res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw) }); + res.end(raw); + }); + srv.listen(0, '127.0.0.1', () => { + const port = srv.address().port; + resolve({ + port, + url: `http://127.0.0.1:${port}`, + close: () => new Promise(r => srv.close(r)), + }); + }); + srv.once('error', reject); + }); +} + +// ── quota_v2 fixture shapes ── +const QUOTA_V2_LIVE = [ + { + provider: 'anthropic', + status: 'live', + schema_version: '2026-05-26', + last_fresh_at: Date.now() - 60000, + utilization: { '5h': 0.36, '7d': 0.34 }, + reset: { '5h': Math.floor((Date.now() + 2 * 3600 * 1000) / 1000), '7d': Math.floor((Date.now() + 5 * 24 * 3600 * 1000) / 1000) }, + representative_claim: 'five_hour', + fallback_percentage: 0.02, + raw_available: null, + }, + { + provider: 'openai', + status: 'unavailable', + reason: 'no public quota api', + }, + { + provider: 'mistral', + status: 'unavailable', + reason: 'no public quota api', + }, +]; + +const QUOTA_V2_STALE = [ + { + provider: 'anthropic', + status: 'stale', + last_fresh_at: Date.now() - 6 * 60000, + utilization: { '5h': 0.60, '7d': 0.20 }, + reset: { '5h': Math.floor((Date.now() + 3600 * 1000) / 1000), '7d': Math.floor((Date.now() + 4 * 24 * 3600 * 1000) / 1000) }, + representative_claim: 'five_hour', + failure: { kind: 'rate_limited', message: 'too many requests' }, + }, +]; + +const QUOTA_V2_UNREACHABLE = [ + { + provider: 'anthropic', + status: 'unreachable', + failure: { kind: 'auth_failed', message: 're-run `claude setup-token`' }, + }, +]; + +const LEGACY_QUOTA_BODY = { + window_24h: {}, + cache_hit_24h: {}, + quota: [ + { provider: 'anthropic', available: null }, + { provider: 'openai', available: null }, + ], +}; + +describe('Suite 40 — F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin', () => { + + // ── 40a — cmdUsage renders quota_v2 live rows ──────────────────────────── + + it('40a — cmdUsage parses quota_v2 live rows (mock server → asserts key strings present)', async () => { + const dashData = { + window_24h: { request_count: 12, status_2xx: 12, status_4xx: 0, status_5xx: 0 }, + cache_hit_24h: { hit_rate: 0.5, hit: 6, miss: 6 }, + quota: [], + quota_v2: QUOTA_V2_LIVE, + top_fallback_chains_24h: [], + cache_stats: { hits: 6, misses: 6, size: 4, inflightCount: 0 }, + }; + const srv = await makeJsonServer40(dashData); + try { + // Clear auth env so resolveBearerToken returns null (anonymous allowed) + const savedKey = process.env.OLP_API_KEY; + const savedOwner = process.env.OLP_OWNER_TOKEN; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + let out = '', err = ''; + try { + const code = await runOlpCli40( + ['usage', `--proxy-url=${srv.url}`], + { out: s => { out += s; }, err: s => { err += s; }, useColor: false }, + ); + assert.equal(code, 0, `cmdUsage exit non-zero; stderr=${err}`); + } finally { + if (savedKey === undefined) delete process.env.OLP_API_KEY; else process.env.OLP_API_KEY = savedKey; + if (savedOwner === undefined) delete process.env.OLP_OWNER_TOKEN; else process.env.OLP_OWNER_TOKEN = savedOwner; + } + // Must display quota_v2 section heading + assert.match(out, /Per-provider quota.*live/i, '40a: header must mention live quota'); + // Must show anthropic provider name + assert.match(out, /ANTHROPIC/, '40a: ANTHROPIC row must appear'); + // Must show percentage data (36% or 34% from utilization shape) + assert.match(out, /36%|34%/, '40a: live utilization must appear'); + // Must show reset countdown strings + assert.match(out, /resets in|resets /i, '40a: reset countdown must appear'); + // Legacy "no quota api" fallthrough must NOT appear + assert.ok(!out.includes('no quota api'), '40a: "no quota api" must not appear when quota_v2 is present'); + } finally { + await srv.close(); + } + }); + + // ── 40b — cmdUsage falls back to legacy body.quota when quota_v2 absent ── + + it('40b — cmdUsage falls back to legacy body.quota when quota_v2 absent (older server)', async () => { + const srv = await makeJsonServer40(LEGACY_QUOTA_BODY); + try { + const savedKey = process.env.OLP_API_KEY; + const savedOwner = process.env.OLP_OWNER_TOKEN; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + let out = '', err = ''; + try { + const code = await runOlpCli40( + ['usage', `--proxy-url=${srv.url}`], + { out: s => { out += s; }, err: s => { err += s; }, useColor: false }, + ); + assert.equal(code, 0, `cmdUsage fallback exit non-zero; stderr=${err}`); + } finally { + if (savedKey === undefined) delete process.env.OLP_API_KEY; else process.env.OLP_API_KEY = savedKey; + if (savedOwner === undefined) delete process.env.OLP_OWNER_TOKEN; else process.env.OLP_OWNER_TOKEN = savedOwner; + } + // Legacy path shows "no quota api" for providers with null available + assert.match(out, /no quota api/, '40b: legacy path must show "no quota api" fallthrough'); + } finally { + await srv.close(); + } + }); + + // ── 40c — olp-plugin fmtUsage parses quota_v2 live row ────────────────── + + it('40c — olp-plugin fmtUsage parses quota_v2 live row (mock body)', () => { + const body = { + window_24h: { request_count: 5 }, + cache_hit_24h: { hit_rate: 0.4 }, + quota: [], + quota_v2: QUOTA_V2_LIVE, + top_fallback_chains_24h: [], + }; + const out = plugFmtUsage40(body); + // Section heading + assert.match(out, /Per-provider quota.*live/i, '40c: section heading must indicate live quota'); + // Anthropic row present + assert.match(out, /ANTHROPIC/, '40c: ANTHROPIC row must appear'); + // live status + assert.match(out, /live/, '40c: status "live" must appear for anthropic'); + // Utilization percentage + assert.match(out, /36%/, '40c: 5h utilization 36% must appear'); + // Reset countdown present + assert.match(out, /resets in|resets /i, '40c: reset countdown must appear'); + // openai/mistral show unavailable + assert.match(out, /OPENAI.*unavailable|OPENAI\s+unavailable/, '40c: OPENAI must show unavailable'); + // Legacy bar() fallthrough must NOT fire + assert.ok(!out.includes('█'), '40c: legacy bar() must not appear when quota_v2 present'); + }); + + // ── 40d — olp-plugin fmtUsage falls back to legacy body.quota ──────────── + + it('40d — olp-plugin fmtUsage falls back to legacy body.quota when quota_v2 absent', () => { + const body = { + quota: [ + { provider: 'anthropic', percent_used: 45 }, + { provider: 'openai', percent_used: null }, + ], + }; + const out = plugFmtUsage40(body); + assert.match(out, /Per-provider quota/, '40d: section heading must appear'); + // Legacy bar() fires for percent_used=45 + assert.match(out, /anthropic.*45%|45%.*anthropic/i, '40d: anthropic 45% must appear'); + assert.match(out, /no quota api/, '40d: "no quota api" must appear for provider with null percent_used'); + }); + + // ── 40e — formatResetCountdown ranges ──────────────────────────────────── + + it('40e — formatResetCountdown covers all 5 time ranges', () => { + const nowSec = Math.floor(Date.now() / 1000); + // Past: diffMs <= 0 + assert.equal(olpFormatResetCountdown(nowSec - 5), 'resetting now', '40e: past → resetting now'); + // < 1h: exactly 30 minutes ahead by feeding epoch seconds directly (no ms conversion drift) + const t30m = nowSec + 30 * 60; + const r30m = olpFormatResetCountdown(t30m); + assert.match(r30m, /resets in \d+m/, '40e: ~30min → resets in Xm format'); + assert.ok(r30m.includes('resets in'), '40e: 30min must use < 1h branch'); + // < 24h: 3 hours + 15 minutes from now (uses hours+minutes branch) + const t3h15m = nowSec + (3 * 60 + 15) * 60; + const r3h15m = olpFormatResetCountdown(t3h15m); + assert.match(r3h15m, /resets in \d+h/, '40e: 3h15m → resets in Xh format'); + // < 7d: 2 days from now → weekday format + const t2d = nowSec + 2 * 24 * 3600; + const r2d = olpFormatResetCountdown(t2d); + assert.match(r2d, /resets (Mon|Tue|Wed|Thu|Fri|Sat|Sun)/, '40e: 2 days → weekday format'); + // >= 7d: 10 days from now → month+day format + const t10d = nowSec + 10 * 24 * 3600; + const r10d = olpFormatResetCountdown(t10d); + assert.match(r10d, /resets (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/, '40e: 10 days → month format'); + // null input + assert.equal(olpFormatResetCountdown(null), '—', '40e: null → —'); + }); + + // ── 40f — plugin formatResetCountdown parallel coverage ───────────────── + + it('40f — olp-plugin pluginFormatResetCountdown covers past / <1h / <24h / <7d / ≥7d', () => { + const nowSec = Math.floor(Date.now() / 1000); + assert.equal(pluginFormatResetCountdown(nowSec - 1), 'resetting now', '40f: past → resetting now'); + // ~45 minutes ahead → < 1h branch + const t45m = nowSec + 45 * 60; + const r45m = pluginFormatResetCountdown(t45m); + assert.match(r45m, /resets in \d+m/, '40f: ~45min → resets in Xm format'); + // ~2h ahead (well within <24h range — may read as 1h 59m due to sub-second drift) + const t2h = nowSec + 2 * 3600; + const r2h = pluginFormatResetCountdown(t2h); + assert.match(r2h, /resets in \d+h/, '40f: ~2h → resets in Xh format'); + assert.equal(pluginFormatResetCountdown(null), '—', '40f: null → —'); + }); + + // ── 40g — cmdUsage renders quota_v2 stale row ──────────────────────────── + + it('40g — cmdUsage renders quota_v2 stale row with ⚠ stale note', async () => { + const dashData = { + window_24h: {}, + cache_hit_24h: {}, + quota: [], + quota_v2: QUOTA_V2_STALE, + top_fallback_chains_24h: [], + }; + const srv = await makeJsonServer40(dashData); + try { + const savedKey = process.env.OLP_API_KEY; + const savedOwner = process.env.OLP_OWNER_TOKEN; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + let out = '', err = ''; + try { + await runOlpCli40( + ['usage', `--proxy-url=${srv.url}`], + { out: s => { out += s; }, err: s => { err += s; }, useColor: false }, + ); + } finally { + if (savedKey === undefined) delete process.env.OLP_API_KEY; else process.env.OLP_API_KEY = savedKey; + if (savedOwner === undefined) delete process.env.OLP_OWNER_TOKEN; else process.env.OLP_OWNER_TOKEN = savedOwner; + } + assert.match(out, /stale/, '40g: stale status must appear in output'); + } finally { + await srv.close(); + } + }); + + // ── 40h — cmdUsage renders quota_v2 unreachable row ───────────────────── + + it('40h — cmdUsage renders quota_v2 unreachable row with ❌ indicator', async () => { + const dashData = { + window_24h: {}, + cache_hit_24h: {}, + quota: [], + quota_v2: QUOTA_V2_UNREACHABLE, + top_fallback_chains_24h: [], + }; + const srv = await makeJsonServer40(dashData); + try { + const savedKey = process.env.OLP_API_KEY; + const savedOwner = process.env.OLP_OWNER_TOKEN; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + let out = '', err = ''; + try { + await runOlpCli40( + ['usage', `--proxy-url=${srv.url}`], + { out: s => { out += s; }, err: s => { err += s; }, useColor: false }, + ); + } finally { + if (savedKey === undefined) delete process.env.OLP_API_KEY; else process.env.OLP_API_KEY = savedKey; + if (savedOwner === undefined) delete process.env.OLP_OWNER_TOKEN; else process.env.OLP_OWNER_TOKEN = savedOwner; + } + assert.match(out, /❌|no cached data/, '40h: unreachable row must show ❌ or "no cached data"'); + assert.match(out, /auth_failed/, '40h: failure kind must appear in output'); + } finally { + await srv.close(); + } + }); + + // ── 40i — cmdUsage renders quota_v2 unavailable row ───────────────────── + + it('40i — cmdUsage renders quota_v2 unavailable rows (openai / mistral) correctly', async () => { + const dashData = { + window_24h: {}, + cache_hit_24h: {}, + quota: [], + quota_v2: QUOTA_V2_LIVE, // includes openai + mistral as unavailable + top_fallback_chains_24h: [], + }; + const srv = await makeJsonServer40(dashData); + try { + const savedKey = process.env.OLP_API_KEY; + const savedOwner = process.env.OLP_OWNER_TOKEN; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + let out = '', err = ''; + try { + await runOlpCli40( + ['usage', `--proxy-url=${srv.url}`], + { out: s => { out += s; }, err: s => { err += s; }, useColor: false }, + ); + } finally { + if (savedKey === undefined) delete process.env.OLP_API_KEY; else process.env.OLP_API_KEY = savedKey; + if (savedOwner === undefined) delete process.env.OLP_OWNER_TOKEN; else process.env.OLP_OWNER_TOKEN = savedOwner; + } + // openai + mistral are unavailable in the fixture + assert.match(out, /OPENAI/, '40i: OPENAI row must appear'); + assert.match(out, /unavailable/, '40i: "unavailable" must appear for providers with no quota api'); + } finally { + await srv.close(); + } + }); +});