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:
@@ -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
|
||||
* `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_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,
|
||||
|
||||
Reference in New Issue
Block a user