mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat: D81 — dashboard-data quota_v2 shape + models-registry schema_version (Phase 5) (#53)
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:<error.message> }
- 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 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,93 @@
|
|||||||
|
|
||||||
- **Date:** 2026-05-25
|
- **Date:** 2026-05-25
|
||||||
- **Status:** Accepted (D48, design-only — implementation D-days D49–D54 follow; Phase 3 close = v0.3.0)
|
- **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<name, plugin> or plain object
|
||||||
|
getQuotaStatus, // optional injectable getter (name) => Promise<shape|null>
|
||||||
|
}): Promise<Array<ProviderQuotaEntry>>
|
||||||
|
```
|
||||||
|
|
||||||
|
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: <error.message>, ...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<ProviderQuotaEntry>` — 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: <error> }` 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)
|
- **Authors:** project maintainer (with AI drafting assistance)
|
||||||
- **Related:**
|
- **Related:**
|
||||||
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
|
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
|
||||||
|
|||||||
@@ -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<string, object>} args.providers - Map of provider name → plugin object
|
||||||
|
* @param {(name: string) => Promise<object|null>} [args.getQuotaStatus] - injectable for tests;
|
||||||
|
* defaults to calling providers.get(name).quotaStatus?.()
|
||||||
|
* @returns {Promise<Array<{
|
||||||
|
* provider: string,
|
||||||
|
* status: 'live' | 'stale' | 'unavailable' | 'disabled',
|
||||||
|
* reason?: string,
|
||||||
|
* schema_version: string | null,
|
||||||
|
* last_fresh_at: number | null,
|
||||||
|
* 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,
|
||||||
|
* }>>}
|
||||||
|
*/
|
||||||
|
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
|
* Audit-derived cache hit rate over the window. Differs from
|
||||||
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
||||||
|
|||||||
@@ -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_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_MIN_MS = 60 * 1000; // 60s (OCP OAUTH_REFRESH_MIN_BACKOFF)
|
||||||
const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MAX_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';
|
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).
|
// Module-level probe state — one instance per process (not per request).
|
||||||
// Ported from OCP server.mjs:852 (usageCache) + 862 (oauthRefreshBackoff).
|
// Ported from OCP server.mjs:852 (usageCache) + 862 (oauthRefreshBackoff).
|
||||||
const quotaProbeState = {
|
const quotaProbeState = {
|
||||||
@@ -788,7 +814,9 @@ export async function quotaStatus(_authContext) {
|
|||||||
const shape = {
|
const shape = {
|
||||||
probedAt: now,
|
probedAt: now,
|
||||||
source: 'anthropic-ratelimit-unified-headers',
|
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,
|
stale: false,
|
||||||
fields: probeResult.fields,
|
fields: probeResult.fields,
|
||||||
raw: probeResult.raw,
|
raw: probeResult.raw,
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1.0-bootstrap",
|
"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.",
|
"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,
|
"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'.",
|
"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": {
|
"providers": {
|
||||||
|
|||||||
+29
-2
@@ -71,11 +71,13 @@ import {
|
|||||||
} from './lib/keys.mjs';
|
} from './lib/keys.mjs';
|
||||||
import { appendAuditEvent } from './lib/audit.mjs';
|
import { appendAuditEvent } from './lib/audit.mjs';
|
||||||
// Phase 3 / D50 — management endpoints consume the audit aggregate query layer.
|
// Phase 3 / D50 — management endpoints consume the audit aggregate query layer.
|
||||||
|
// D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape.
|
||||||
import {
|
import {
|
||||||
aggregateRequests as auditAggregateRequests,
|
aggregateRequests as auditAggregateRequests,
|
||||||
topFallbackChains as auditTopFallbackChains,
|
topFallbackChains as auditTopFallbackChains,
|
||||||
spendTrendDaily as auditSpendTrendDaily,
|
spendTrendDaily as auditSpendTrendDaily,
|
||||||
cacheHitRateWindow as auditCacheHitRateWindow,
|
cacheHitRateWindow as auditCacheHitRateWindow,
|
||||||
|
aggregateProviderQuota as auditAggregateProviderQuota,
|
||||||
} from './lib/audit-query.mjs';
|
} from './lib/audit-query.mjs';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
@@ -2059,8 +2061,10 @@ async function handleDashboard(req, res) {
|
|||||||
async function handleManagementDashboardData(req, res) {
|
async function handleManagementDashboardData(req, res) {
|
||||||
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data',
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data',
|
||||||
async (_req, res2, _identity, _auditCtx) => {
|
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.
|
// 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 = [];
|
const quota = [];
|
||||||
for (const [name, provider] of loadedProviders) {
|
for (const [name, provider] of loadedProviders) {
|
||||||
try {
|
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 WINDOW_24H = 24 * 60 * 60 * 1000;
|
||||||
const payload = {
|
const payload = {
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }),
|
window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }),
|
||||||
cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }),
|
cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }),
|
||||||
quota,
|
quota,
|
||||||
|
quota_v2,
|
||||||
spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }),
|
spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }),
|
||||||
top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }),
|
top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }),
|
||||||
cache_stats: cacheStore.stats(),
|
cache_stats: cacheStore.stats(),
|
||||||
@@ -2093,6 +2112,7 @@ async function handleManagementDashboardData(req, res) {
|
|||||||
async function handleManagementQuota(req, res) {
|
async function handleManagementQuota(req, res) {
|
||||||
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota',
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota',
|
||||||
async (_req, res2, _identity, _auditCtx) => {
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
// Legacy quota array (backwards compat).
|
||||||
const quota = [];
|
const quota = [];
|
||||||
for (const [name, provider] of loadedProviders) {
|
for (const [name, provider] of loadedProviders) {
|
||||||
try {
|
try {
|
||||||
@@ -2102,7 +2122,14 @@ async function handleManagementQuota(req, res) {
|
|||||||
quota.push({ provider: name, error: err?.message ?? String(err), available: null });
|
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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10997,6 +10997,7 @@ import {
|
|||||||
topFallbackChains,
|
topFallbackChains,
|
||||||
spendTrendDaily,
|
spendTrendDaily,
|
||||||
cacheHitRateWindow,
|
cacheHitRateWindow,
|
||||||
|
aggregateProviderQuota,
|
||||||
} from './lib/audit-query.mjs';
|
} from './lib/audit-query.mjs';
|
||||||
import { mkdirSync, writeFileSync as fsWriteFileSyncForS23 } from 'node:fs';
|
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');
|
'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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user