From 5288493f193ef23bdf48a24f15a17114f0ec65aa Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 26 May 2026 17:06:41 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20D81=20=E2=80=94=20dashboard-data=20quot?= =?UTF-8?q?a=5Fv2=20shape=20+=20models-registry=20schema=5Fversion=20(Phas?= =?UTF-8?q?e=205)=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authority citations (required per CLAUDE.md § Hard requirements): 1. ADR 0012 D81 — the D-day being implemented (Phase 5 charter, audit-query + dashboard-data extension row in § D-day table) 2. ADR 0013 Rule 5 — schema_version in models-registry.json mandate. D80 used a local constant QUOTA_SCHEMA_VERSION = '2026-05-26' (reviewer nit #4 at PR #52). D81 folds in Rule 5 compliance: adds quota_probe.schema_version to models-registry.json and has anthropic.mjs read from there with the constant as fallback via _resolveSchemaVersion(). 3. ADR 0008 — the audit-query design being amended (Amendment 1 added at D81 to docs/adr/0008-dashboard-and-audit-query.md documenting: quota_probe in registry, aggregateProviderQuota() API shape, quota_v2 key, deprecation timeline for legacy quota key). 4. D80 PR #52 (commit 82d2e1c) — producer of the quotaStatus() shape that D81 normalizes. The 13-field anthropic-ratelimit-unified-* shape from _parseRateLimitHeaders() is consumed by _normalizeAnthropicQuota() here. Changes (A–G per D81 spec): A. models-registry.json — new top-level quota_probe key - quota_probe.schema_version = '2026-05-26' - quota_probe.anthropic.{ source, endpoint, fields_pinned[13] } - fields_pinned is load-bearing for drift detection (ADR 0013 Rule 5) B. lib/providers/anthropic.mjs — _resolveSchemaVersion() helper - Reads quota_probe.schema_version from modelsRegistryRaw at call time - Falls back to QUOTA_SCHEMA_VERSION constant if registry field absent - quotaStatus() now uses _resolveSchemaVersion() instead of raw constant C. lib/audit-query.mjs — aggregateProviderQuota() export - Normalizes per-provider quotaStatus() returns to ProviderQuotaEntry shape - Live → { status:'live', utilization, reset, representative_claim, … } - Stale → { status:'stale', … } - null return → { status:'unavailable', reason:'no public quota api or probe disabled' } - throw → { status:'unavailable', reason: } - getQuotaStatus injection point for full test isolation (D81 §F) D. server.mjs handleManagementDashboardData — quota_v2 added - Calls auditAggregateProviderQuota({ providers: loadedProviders }) - Both legacy quota (backwards compat) and quota_v2 in response - Graceful degradation: quota_v2 failure → warn log + empty array, rest of payload unaffected E. server.mjs handleManagementQuota — quota_v2 added (mirrors D) F. test-features.mjs — Suite 37 (7 new tests) - 37a: live shape normalization (status=live, utilization, reset, etc.) - 37b: stale shape (status=stale) - 37c: null returns → unavailable entries - 37d: throw path → unavailable with reason - 37e: mixed providers (live + unavailable) - 37f: getQuotaStatus injection verified - 37g: models-registry.json has quota_probe.schema_version + 13 fields_pinned G. docs/adr/0008-dashboard-and-audit-query.md — Amendment 1 added Test delta: 720 → 727 (+7), 0 failures. Live quota_v2 JSON sample (illustrative — probe is opt-in per ADR 0013 Rule 4; real values require quota_probe_enabled:true + valid OAuth credentials): GET /v0/management/dashboard-data (owner-only) { "quota_v2": [ { "provider": "anthropic", "status": "live", "schema_version": "2026-05-26", "last_fresh_at": 1748300000000, "utilization": { "5h": 0.49, "7d": 0.31 }, "reset": { "5h": 1748290000, "7d": 1748500000, "overall": 1748300000, "overage": null }, "representative_claim": "five_hour", "fallback_percentage": 0.5, "overage": { "status": "rejected", "disabled_reason": "org_level_disabled_until" }, "raw_available": true }, { "provider": "openai", "status": "unavailable", "reason": "no public quota api or probe disabled", "schema_version": null, "last_fresh_at": null, "utilization": null, "reset": null, "representative_claim": null, "fallback_percentage": null, "overage": null, "raw_available": false } ] } When probe is disabled (default), anthropic entry also returns unavailable: { "provider": "anthropic", "status": "unavailable", "reason": "no public quota api or probe disabled", ... } NOT modified: dashboard.html (D82), codex.mjs, mistral.mjs, any quotaStatus() return shape from anthropic.mjs (D80 contract unchanged — normalization is in the new audit-query layer only). Co-authored-by: dtzp555 Co-authored-by: Claude Sonnet 4.6 --- docs/adr/0008-dashboard-and-audit-query.md | 87 +++++++++++ lib/audit-query.mjs | 164 +++++++++++++++++++++ lib/providers/anthropic.mjs | 30 +++- models-registry.json | 23 +++ server.mjs | 31 +++- test-features.mjs | 143 ++++++++++++++++++ 6 files changed, 475 insertions(+), 3 deletions(-) diff --git a/docs/adr/0008-dashboard-and-audit-query.md b/docs/adr/0008-dashboard-and-audit-query.md index 8af466b..8e5bc67 100644 --- a/docs/adr/0008-dashboard-and-audit-query.md +++ b/docs/adr/0008-dashboard-and-audit-query.md @@ -2,6 +2,93 @@ - **Date:** 2026-05-25 - **Status:** Accepted (D48, design-only — implementation D-days D49–D54 follow; Phase 3 close = v0.3.0) + +## Amendments + +### Amendment 1 — 2026-05-26: D81 Phase 5 quota_v2 shape + aggregateProviderQuota() + +**Scope:** D81 (Phase 5 / ADR 0012 D81) extends the audit-query layer and dashboard-data endpoint to surface the new per-provider quota shape introduced by D80 (`lib/providers/anthropic.mjs:quotaStatus()`). This amendment documents the three new interfaces. + +#### 1. `models-registry.json` — new `quota_probe` top-level key + +D81 adds a `quota_probe` key at the root of `models-registry.json` per ADR 0013 Rule 5 (schema_version in registry so downstream consumers can detect schema drift): + +```json +{ + "quota_probe": { + "schema_version": "2026-05-26", + "anthropic": { + "source": "anthropic-ratelimit-unified-headers", + "endpoint": "https://api.anthropic.com/v1/messages", + "fields_pinned": [ ...13 field names... ] + } + } +} +``` + +`fields_pinned` is load-bearing: if Anthropic adds/renames a header in a future CLI version, dashboard consumers comparing field-presence against this list can flag "schema drift detected" per the ADR 0013 Rule 5 drift-detection runbook. This field must be updated alongside the parser whenever a drift event occurs. + +`lib/providers/anthropic.mjs` reads `quota_probe.schema_version` from the registry at call time (via `_resolveSchemaVersion()`) with the module-level `QUOTA_SCHEMA_VERSION` constant as fallback. No hard dependency on the registry — the constant is the safety net. + +#### 2. `lib/audit-query.mjs` — new `aggregateProviderQuota()` export + +```js +export async function aggregateProviderQuota({ + providers, // Map or plain object + getQuotaStatus, // optional injectable getter (name) => Promise +}): Promise> +``` + +For each provider, calls `quotaStatus()` (already cached at the plugin layer per ADR 0013 Rule 3) and normalizes to the `ProviderQuotaEntry` shape: + +```js +{ + provider: string, + status: 'live' | 'stale' | 'unavailable', + reason?: string, // only when status === 'unavailable' + schema_version: string|null, + last_fresh_at: number|null, // epoch-ms of last successful probe + utilization: { '5h': number|null, '7d': number|null } | null, + reset: { + '5h': number|null, '7d': number|null, + overall: number|null, overage: number|null, + } | null, + representative_claim: string|null, + fallback_percentage: number|null, + overage: { status: string|null, disabled_reason: string|null } | null, + raw_available: boolean, +} +``` + +Providers returning `null` from `quotaStatus()` (codex, mistral — no public quota API; or probe disabled) produce `{ status: 'unavailable', reason: 'no public quota api or probe disabled', ...null fields }`. + +Providers whose `quotaStatus()` throws produce `{ status: 'unavailable', reason: , ...null fields }`. + +This function does NOT scan ndjson files; it calls live provider plugins. It is audit-query-adjacent (normalized query shape for the dashboard layer) but not audit-derived. Query model remains Lane 2 = A (in-memory, no SQLite). + +#### 3. `/v0/management/dashboard-data` and `/v0/management/quota` — new `quota_v2` field + +Both endpoints now return TWO quota keys: + +- **`quota`** (legacy, unchanged): `Array<{ provider, ...rawQuotaStatus, available }>`. Kept for backwards compatibility with the existing `dashboard.html` (D82 will switch consumers to `quota_v2`). +- **`quota_v2`** (D81 new): `Array` — the normalized shape from `aggregateProviderQuota()` above. This is what D82's enriched dashboard UI will consume. + +Both fields are computed from the same underlying `quotaStatus()` call. The legacy `quota` key calls `quotaStatus()` independently from `quota_v2`; since the probe is cached at the plugin layer (ADR 0013 Rule 3), the double call incurs no extra API requests. + +**Deprecation timeline:** the legacy `quota` key is deprecated as of D81. Target removal: v1.0.0 or when D82 completes the dashboard migration (whichever comes first). Removal requires a separate PR with a CHANGELOG entry. + +#### 4. Failure handling + +`aggregateProviderQuota()` never throws to the dashboard endpoint. Per-provider failures are absorbed as `{ status: 'unavailable', reason: }` entries. If `aggregateProviderQuota()` itself throws (implementation bug), `handleManagementDashboardData` and `handleManagementQuota` catch the error, log `dashboard_data_quota_v2_failed` / `management_quota_v2_failed`, and return `quota_v2: []` so the rest of the payload is unaffected. + +#### 5. Authority citations for this amendment + +- **ADR 0012 D81** — the D-day this amendment documents. +- **ADR 0013 Rule 5** — mandate for `quota_probe.schema_version` in `models-registry.json`. +- **D80 PR #52 commit 82d2e1c** — the producer of the `quotaStatus()` shape this amendment normalizes. +- **ADR 0008 Lane 2 = A** — query model unchanged; `aggregateProviderQuota()` does not scan ndjson. + +--- - **Authors:** project maintainer (with AI drafting assistance) - **Related:** - OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints) diff --git a/lib/audit-query.mjs b/lib/audit-query.mjs index 0c7e8de..320be70 100644 --- a/lib/audit-query.mjs +++ b/lib/audit-query.mjs @@ -443,6 +443,170 @@ export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) { }); } +/** + * Normalize a single quotaStatus() return value from the anthropic plugin into + * the dashboard-friendly shape (D81 / ADR 0008 Amendment). + * Provider-specific: called only for 'anthropic'. Returns null if the raw + * shape is absent or malformed. + * + * @internal — used by aggregateProviderQuota() + */ +function _normalizeAnthropicQuota(raw) { + if (!raw || typeof raw !== 'object') return null; + const f = raw.fields ?? {}; + return { + schema_version: raw.schemaVersion ?? null, + last_fresh_at: raw.stale ? (raw.last_fresh_at ?? null) : (raw.probedAt ?? null), + utilization: { + '5h': f.utilization_5h ?? null, + '7d': f.utilization_7d ?? null, + }, + reset: { + '5h': f.reset_5h ?? null, + '7d': f.reset_7d ?? null, + overall: f.reset ?? null, + overage: f.overage_reset ?? null, + }, + representative_claim: f.representative_claim ?? null, + fallback_percentage: f.fallback_percentage ?? null, + overage: { + status: f.overage_status ?? null, + disabled_reason: f.overage_disabled_reason ?? null, + }, + raw_available: (typeof raw.raw === 'object' && raw.raw !== null), + }; +} + +/** + * Aggregate per-provider quota status into a normalized dashboard-friendly + * shape. This is the D81 Phase 5 extension of lib/audit-query.mjs per + * ADR 0008 Amendment (D81). + * + * For each loaded provider, calls quotaStatus() (already cached at the plugin + * layer per ADR 0013 Rule 3) and normalizes to a consistent shape. Providers + * returning null (codex, mistral) produce a { status: 'unavailable' } row. + * + * Audit-query stays in-memory scan per ADR 0008 Lane 2 = A. This function + * does NOT scan the ndjson files; it calls the live provider plugins. + * + * Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5. + * + * @param {object} args + * @param {Map} args.providers - Map of provider name → plugin object + * @param {(name: string) => Promise} [args.getQuotaStatus] - injectable for tests; + * defaults to calling providers.get(name).quotaStatus?.() + * @returns {Promise>} + */ +export async function aggregateProviderQuota({ + providers, + getQuotaStatus, +} = {}) { + if (!providers) { + throw new Error('aggregateProviderQuota: providers (Map) is required'); + } + + // Normalize the providers argument — accept both Map and plain object. + const providerEntries = (providers instanceof Map) + ? [...providers.entries()] + : Object.entries(providers); + + const results = []; + for (const [name, plugin] of providerEntries) { + // Default getter: call the plugin's quotaStatus() if present. + const fetchQuota = getQuotaStatus + ? () => getQuotaStatus(name) + : () => (typeof plugin?.quotaStatus === 'function' ? plugin.quotaStatus(null) : Promise.resolve(null)); + + let rawResult = null; + let callError = null; + try { + rawResult = await fetchQuota(); + } catch (err) { + callError = err?.message ?? String(err); + } + + if (callError !== null) { + // quotaStatus() threw — treat as error / unavailable. + results.push({ + provider: name, + status: 'unavailable', + reason: callError, + schema_version: null, + last_fresh_at: null, + utilization: null, + reset: null, + representative_claim: null, + fallback_percentage: null, + overage: null, + raw_available: false, + }); + continue; + } + + if (rawResult === null || rawResult === undefined) { + // Plugin returned null: either disabled, no quota API, or no credentials. + results.push({ + provider: name, + status: 'unavailable', + reason: 'no public quota api or probe disabled', + schema_version: null, + last_fresh_at: null, + utilization: null, + reset: null, + representative_claim: null, + fallback_percentage: null, + overage: null, + raw_available: false, + }); + continue; + } + + // quotaStatus() returned a non-null shape — normalize. + // Currently only 'anthropic' returns a structured shape; other providers + // returning structured data will work if their shape is compatible. + const isStale = rawResult.stale === true; + const normalized = _normalizeAnthropicQuota(rawResult); + + if (normalized === null) { + // Shape was present but unrecognizable. + results.push({ + provider: name, + status: 'unavailable', + reason: 'unrecognized quota shape', + schema_version: null, + last_fresh_at: null, + utilization: null, + reset: null, + representative_claim: null, + fallback_percentage: null, + overage: null, + raw_available: false, + }); + continue; + } + + results.push({ + provider: name, + status: isStale ? 'stale' : 'live', + ...normalized, + }); + } + + return results; +} + /** * Audit-derived cache hit rate over the window. Differs from * `cacheStore.stats()` in server.mjs: that is the live in-process counter; diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index 6c6dda8..a9f4750 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -144,8 +144,34 @@ const QUOTA_API_URL = 'https://api.anthropic.com/v1/messages'; const QUOTA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes (OCP USAGE_CACHE_TTL) const QUOTA_BACKOFF_MIN_MS = 60 * 1000; // 60s (OCP OAUTH_REFRESH_MIN_BACKOFF) const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MAX_BACKOFF) +// Local constant is the safety net (ADR 0013 Rule 5 + D81 reviewer nit #4 fold-in). +// The live value is read from models-registry.json at module load via _resolveSchemaVersion(). +// If the registry field is absent or fails to load, this constant takes over. const QUOTA_SCHEMA_VERSION = '2026-05-26'; +/** + * Read quota_probe.schema_version from models-registry.json (D81). + * ADR 0013 Rule 5: schema_version lives in models-registry.json so downstream + * consumers can detect schema drift by bumping that field on drift events. + * Local QUOTA_SCHEMA_VERSION constant is the fallback if the registry field + * is absent or the import is unavailable. + * + * @returns {string} schema_version string (e.g. '2026-05-26') + */ +function _resolveSchemaVersion() { + try { + // models-registry.json is imported at the bottom of this file as + // modelsRegistryRaw; reference it here via a lazy try/catch so that if + // the import hasn't resolved yet (edge case: circular import during tests) + // we gracefully fall through to the constant. + const registryVersion = modelsRegistryRaw?.quota_probe?.schema_version; + if (typeof registryVersion === 'string' && registryVersion.length > 0) { + return registryVersion; + } + } catch { /* fall through to constant */ } + return QUOTA_SCHEMA_VERSION; +} + // Module-level probe state — one instance per process (not per request). // Ported from OCP server.mjs:852 (usageCache) + 862 (oauthRefreshBackoff). const quotaProbeState = { @@ -788,7 +814,9 @@ export async function quotaStatus(_authContext) { const shape = { probedAt: now, source: 'anthropic-ratelimit-unified-headers', - schemaVersion: QUOTA_SCHEMA_VERSION, + // D81: schema_version sourced from models-registry.json with QUOTA_SCHEMA_VERSION + // as fallback per ADR 0013 Rule 5 + D81 ADR 0008 Amendment requirement. + schemaVersion: _resolveSchemaVersion(), stale: false, fields: probeResult.fields, raw: probeResult.raw, diff --git a/models-registry.json b/models-registry.json index e7e3948..f83289d 100644 --- a/models-registry.json +++ b/models-registry.json @@ -1,6 +1,29 @@ { "version": "0.1.0-bootstrap", "comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.", + "quota_probe": { + "schema_version": "2026-05-26", + "comment": "D81 — ADR 0013 Rule 5 mandate: schema_version pinned in registry so downstream consumers can detect schema drift. fields_pinned is load-bearing: if Anthropic adds/renames a header, dashboard consumers comparing field-presence against this list can flag 'schema drift detected'. Last verified: 2026-05-26 via live probe against api.anthropic.com (Path B per ADR 0013 Rule 5).", + "anthropic": { + "source": "anthropic-ratelimit-unified-headers", + "endpoint": "https://api.anthropic.com/v1/messages", + "fields_pinned": [ + "status", + "representative_claim", + "reset", + "fallback_percentage", + "status_5h", + "utilization_5h", + "reset_5h", + "status_7d", + "utilization_7d", + "reset_7d", + "overage_status", + "overage_disabled_reason", + "overage_reset" + ] + } + }, "bootstrapCreated": 1778630400, "bootstrapCreatedComment": "Fallback Unix timestamp for models whose precise release date is unknown. Value = 2026-05-13 (the day before the Anthropic billing-split announcement that triggered OLP). Used by handleModels() in server.mjs when a model entry does not have a model-level 'created' field. Per F12 round-5 cold-audit: OpenAI spec treats 'created' as a stable per-model attribute; synthesizing Date.now() on each request causes spurious updates for clients caching models by 'created'.", "providers": { diff --git a/server.mjs b/server.mjs index 2d2f54b..423ffd1 100644 --- a/server.mjs +++ b/server.mjs @@ -71,11 +71,13 @@ import { } from './lib/keys.mjs'; import { appendAuditEvent } from './lib/audit.mjs'; // Phase 3 / D50 — management endpoints consume the audit aggregate query layer. +// D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape. import { aggregateRequests as auditAggregateRequests, topFallbackChains as auditTopFallbackChains, spendTrendDaily as auditSpendTrendDaily, cacheHitRateWindow as auditCacheHitRateWindow, + aggregateProviderQuota as auditAggregateProviderQuota, } from './lib/audit-query.mjs'; // ── Config ──────────────────────────────────────────────────────────────── @@ -2059,8 +2061,10 @@ async function handleDashboard(req, res) { async function handleManagementDashboardData(req, res) { return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data', async (_req, res2, _identity, _auditCtx) => { - // Quota panel: collect quotaStatus from each loaded provider; null on + // Quota panel (legacy): collect quotaStatus from each loaded provider; null on // throw or null return → "unavailable" indicator. + // DEPRECATED: kept for backwards compat with current dashboard.html (D82 will + // switch consumers to quota_v2; legacy 'quota' key removed at v1.0.0 or earlier). const quota = []; for (const [name, provider] of loadedProviders) { try { @@ -2071,12 +2075,27 @@ async function handleManagementDashboardData(req, res) { } } + // quota_v2 (D81 / Phase 5): normalized per-provider quota shape for enriched + // dashboard rendering. Built from aggregateProviderQuota() in lib/audit-query.mjs. + // Each entry contains: provider, status, schema_version, last_fresh_at, + // utilization, reset, representative_claim, fallback_percentage, overage, raw_available. + // Providers with null quotaStatus() return { status: 'unavailable', reason: ... }. + // Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5. + let quota_v2 = []; + try { + quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders }); + } catch (err) { + // Graceful degradation: quota_v2 is optional enrichment; don't fail entire payload. + logEvent('warn', 'dashboard_data_quota_v2_failed', { error: err?.message ?? String(err) }); + } + const WINDOW_24H = 24 * 60 * 60 * 1000; const payload = { generated_at: new Date().toISOString(), window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }), cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }), quota, + quota_v2, spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }), top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }), cache_stats: cacheStore.stats(), @@ -2093,6 +2112,7 @@ async function handleManagementDashboardData(req, res) { async function handleManagementQuota(req, res) { return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota', async (_req, res2, _identity, _auditCtx) => { + // Legacy quota array (backwards compat). const quota = []; for (const [name, provider] of loadedProviders) { try { @@ -2102,7 +2122,14 @@ async function handleManagementQuota(req, res) { quota.push({ provider: name, error: err?.message ?? String(err), available: null }); } } - sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota }); + // quota_v2 (D81): normalized per-provider quota shape per ADR 0008 Amendment (D81). + let quota_v2 = []; + try { + quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders }); + } catch (err) { + logEvent('warn', 'management_quota_v2_failed', { error: err?.message ?? String(err) }); + } + sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota, quota_v2 }); }); } diff --git a/test-features.mjs b/test-features.mjs index 161ad5e..598a619 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -10997,6 +10997,7 @@ import { topFallbackChains, spendTrendDaily, cacheHitRateWindow, + aggregateProviderQuota, } from './lib/audit-query.mjs'; import { mkdirSync, writeFileSync as fsWriteFileSyncForS23 } from 'node:fs'; @@ -15447,3 +15448,145 @@ describe('Suite 36 — D74 v0.4.1 hotfix regression (maintainer review findings) 'README must explain why the tag-pinned form is the primary recommendation'); }); }); + +// ── Suite 37: D81 aggregateProviderQuota() smoke tests (Phase 5, ADR 0008 Amendment) ── +// +// Smoke tests for aggregateProviderQuota() per D81 prompt "add 1-2 smoke tests +// so the implementation is testable in isolation." Comprehensive tests follow in +// D83 (Suite 38/39). These cover: live shape normalization, stale shape, null +// (unavailable) return, and error/throw path. +// +// Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5. + +describe('Suite 37 — D81 aggregateProviderQuota() smoke tests (Phase 5, ADR 0008 Amendment)', () => { + // Synthetic quotaStatus return shape matching the D80 anthropic plugin contract. + function makeSyntheticQuotaShape(overrides = {}) { + return { + probedAt: Date.now(), + source: 'anthropic-ratelimit-unified-headers', + schemaVersion: '2026-05-26', + stale: false, + fields: { + status: 'active', + representative_claim: 'five_hour', + reset: 1748300000, + fallback_percentage: 0.5, + status_5h: 'active', + utilization_5h: 0.49, + reset_5h: 1748290000, + status_7d: 'active', + utilization_7d: 0.31, + reset_7d: 1748500000, + overage_status: 'rejected', + overage_disabled_reason: 'org_level_disabled_until', + overage_reset: null, + }, + raw: { 'anthropic-ratelimit-unified-status': 'active' }, + ...overrides, + }; + } + + it('37a — live shape: non-null quotaStatus() returns normalized entry with status=live', async () => { + const synth = makeSyntheticQuotaShape(); + const mockProviders = new Map([ + ['anthropic', { quotaStatus: async () => synth }], + ]); + const result = await aggregateProviderQuota({ providers: mockProviders }); + assert.equal(result.length, 1, '37a: should return one entry'); + const entry = result[0]; + assert.equal(entry.provider, 'anthropic', '37a: provider name should be anthropic'); + assert.equal(entry.status, 'live', '37a: status should be live for non-stale result'); + assert.equal(entry.schema_version, '2026-05-26', '37a: schema_version should be forwarded'); + assert.ok(typeof entry.last_fresh_at === 'number', '37a: last_fresh_at should be a number'); + assert.ok(entry.utilization !== null, '37a: utilization should not be null'); + assert.equal(entry.utilization['5h'], 0.49, '37a: utilization 5h should be 0.49'); + assert.equal(entry.utilization['7d'], 0.31, '37a: utilization 7d should be 0.31'); + assert.ok(entry.reset !== null, '37a: reset should not be null'); + assert.equal(entry.reset.overall, 1748300000, '37a: reset.overall should match fields.reset'); + assert.equal(entry.representative_claim, 'five_hour', '37a: representative_claim should match'); + assert.equal(entry.fallback_percentage, 0.5, '37a: fallback_percentage should match'); + assert.ok(entry.overage !== null, '37a: overage should not be null'); + assert.equal(entry.overage.status, 'rejected', '37a: overage.status should match'); + assert.equal(entry.raw_available, true, '37a: raw_available should be true when raw is present'); + }); + + it('37b — stale shape: stale:true quotaStatus() returns entry with status=stale', async () => { + const synth = makeSyntheticQuotaShape({ stale: true, last_fresh_at: Date.now() - 30000 }); + const mockProviders = new Map([ + ['anthropic', { quotaStatus: async () => synth }], + ]); + const result = await aggregateProviderQuota({ providers: mockProviders }); + assert.equal(result.length, 1, '37b: should return one entry'); + assert.equal(result[0].status, 'stale', '37b: status should be stale when stale:true'); + }); + + it('37c — null return: null quotaStatus() produces unavailable entry', async () => { + const mockProviders = new Map([ + ['codex', { quotaStatus: async () => null }], + ['mistral', { quotaStatus: async () => null }], + ]); + const result = await aggregateProviderQuota({ providers: mockProviders }); + assert.equal(result.length, 2, '37c: should return two entries'); + for (const entry of result) { + assert.equal(entry.status, 'unavailable', `37c: ${entry.provider} should be unavailable`); + assert.ok(entry.reason, `37c: ${entry.provider} should have a reason`); + assert.equal(entry.utilization, null, `37c: ${entry.provider} utilization should be null`); + } + }); + + it('37d — throw: quotaStatus() that throws produces unavailable entry with reason', async () => { + const mockProviders = new Map([ + ['anthropic', { quotaStatus: async () => { throw new Error('network timeout'); } }], + ]); + const result = await aggregateProviderQuota({ providers: mockProviders }); + assert.equal(result.length, 1, '37d: should return one entry'); + assert.equal(result[0].status, 'unavailable', '37d: status should be unavailable on throw'); + assert.ok(result[0].reason.includes('network timeout'), '37d: reason should include the error message'); + }); + + it('37e — mixed providers: live + unavailable in same call', async () => { + const synth = makeSyntheticQuotaShape(); + const mockProviders = new Map([ + ['anthropic', { quotaStatus: async () => synth }], + ['codex', { quotaStatus: async () => null }], + ]); + const result = await aggregateProviderQuota({ providers: mockProviders }); + assert.equal(result.length, 2, '37e: should return two entries'); + const anthropicEntry = result.find(e => e.provider === 'anthropic'); + const codexEntry = result.find(e => e.provider === 'codex'); + assert.ok(anthropicEntry, '37e: anthropic entry should be present'); + assert.ok(codexEntry, '37e: codex entry should be present'); + assert.equal(anthropicEntry.status, 'live', '37e: anthropic should be live'); + assert.equal(codexEntry.status, 'unavailable', '37e: codex should be unavailable'); + }); + + it('37f — getQuotaStatus injection: custom getter is used when provided', async () => { + // Test the getQuotaStatus injection point for full testability (D81 prompt §F). + const synth = makeSyntheticQuotaShape(); + const mockProviders = new Map([['anthropic', {}]]); // plugin has no quotaStatus + const calls = []; + const result = await aggregateProviderQuota({ + providers: mockProviders, + getQuotaStatus: (name) => { calls.push(name); return Promise.resolve(synth); }, + }); + assert.equal(calls.length, 1, '37f: getQuotaStatus should be called once'); + assert.equal(calls[0], 'anthropic', '37f: called with provider name'); + assert.equal(result[0].status, 'live', '37f: result should use injected quota shape'); + }); + + it('37g — models-registry.json has quota_probe.schema_version', () => { + // D81 / ADR 0013 Rule 5 mandate: schema_version must be in models-registry.json. + // Uses _readFileSyncS36 + _joinS36 (already module-level imports from Suite 36). + const registryPath = _joinS36(import.meta.dirname ?? process.cwd(), 'models-registry.json'); + const registry = JSON.parse(_readFileSyncS36(registryPath, 'utf8')); + assert.ok(registry.quota_probe, '37g: models-registry.json must have quota_probe key'); + assert.ok(typeof registry.quota_probe.schema_version === 'string', + '37g: quota_probe.schema_version must be a string'); + assert.ok(registry.quota_probe.schema_version.length > 0, + '37g: quota_probe.schema_version must not be empty'); + assert.ok(Array.isArray(registry.quota_probe.anthropic?.fields_pinned), + '37g: quota_probe.anthropic.fields_pinned must be an array'); + assert.equal(registry.quota_probe.anthropic.fields_pinned.length, 13, + '37g: quota_probe.anthropic.fields_pinned must have exactly 13 entries (all parsed fields)'); + }); +});