feat: D80 — anthropic plan-usage probe port (Phase 5) (#52)

Port OCP server.mjs:842-1109 plan-usage probe to lib/providers/anthropic.mjs:quotaStatus().

## Authority citations (CLAUDE.md hard requirement #1)

1. Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
   — 13-field canonical schema verified live 2026-05-26; 3 fields new vs OCP
   2026-04 capture (5h-status, 7d-status, overage-reset).

2. OCP port source: OCP server.mjs:842-1109 — usageCache, oauthRefreshBackoff,
   getOAuthCredentials, refreshOAuthToken, fetchUsageFromApi, parseRateLimitHeaders.
   Claude Code CLI uses this same POST /v1/messages internally (verified 2026-05-26
   by `strings` on @anthropic-ai/claude-code v2.1.142 / v2.1.150 Mach-O binary — see
   audit memory). This is observed CLI behaviour, not invention.

3. ADR 0002 Amendment 8 — READ-ONLY exemption for quotaStatus() direct-API access.
   Three constraints satisfied: READ-ONLY (max_tokens:1, body discarded),
   subscription-scope (same readAuthArtifact() creds as spawn path),
   idempotent-failure (returns null / stale on any error, never throws).

4. ADR 0013 — OAuth READ-ONLY consumption rules. All 7 rules satisfied:
   Rule 1: credential reuse via readAuthArtifact() (env → .credentials.json → keychain).
   Rule 2: only POST /v1/messages (no other endpoints). Body discarded; headers-only.
   Rule 3: 5min TTL cache; 60s–3600s exponential backoff; stale-on-failure.
   Rule 4: opt-in via ~/.olp/config.json providers.anthropic.quota_probe_enabled (default false).
   Rule 5: schema pin committed to memory file; drift detection protocol in place.
   Rule 6: doctor check anthropic.quota_probe_reachable surfaces probe status.
   Rule 7: does not govern spawn-path refresh (separate concern).

5. ADR 0012 D80 — Phase 5 charter: this commit is the D80 deliverable.

## Live probe transcript (2026-05-26 from MacBook keychain OAuth credentials)

Path B verification per ADR 0013 Rule 5:
  curl -s -i -m 20 -X POST https://api.anthropic.com/v1/messages \
    -H "Authorization: Bearer <token>" \
    -H "anthropic-beta: oauth-2025-04-20" \
    -H "anthropic-version: 2023-06-01" \
    -H "Content-Type: application/json" \
    -d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"."}]}'

Response (header lines only):
  HTTP/2 200
  anthropic-ratelimit-unified-status: allowed
  anthropic-ratelimit-unified-5h-status: allowed
  anthropic-ratelimit-unified-5h-reset: 1779794400
  anthropic-ratelimit-unified-5h-utilization: 0.09
  anthropic-ratelimit-unified-7d-status: allowed
  anthropic-ratelimit-unified-7d-reset: 1780225200
  anthropic-ratelimit-unified-7d-utilization: 0.32
  anthropic-ratelimit-unified-representative-claim: five_hour
  anthropic-ratelimit-unified-fallback-percentage: 0.5
  anthropic-ratelimit-unified-reset: 1779794400
  anthropic-ratelimit-unified-overage-disabled-reason: org_level_disabled_until
  anthropic-ratelimit-unified-overage-status: rejected
  (no anthropic-ratelimit-unified-overage-reset — expected: only present on active overage)

12/13 fields present. overage-reset absent = no active overage (expected per audit memory).
All fields parsed correctly by _parseRateLimitHeaders(). Confirmed via D80 smoke test.

## Implementation

A. quotaStatus() — full probe implementation replacing D4 null stub:
   - _readProviderConfig('anthropic') gate (Rule 4 opt-in)
   - 5min module-level cache check (quotaProbeState.cache)
   - 60s–3600s exponential backoff check (quotaProbeState.backoffUntil / backoffMs)
   - readAuthArtifact() credential read (env → .credentials.json → macOS keychain)
   - _probeOnce() → POST /v1/messages with 4 required headers; body discarded
   - 401/403 → single refresh-and-retry via _refreshAccessToken()
   - On success: cache { fetchedAt, data } + reset backoff to MIN
   - On failure: _scheduleBackoff() (doubles backoffMs, caps at MAX) + return stale or null
   - Return shape: { probedAt, source, schemaVersion, stale, fields:{...13}, raw:{...} }

B. _parseRateLimitHeaders() — all 13 fields (3 new vs OCP):
   - status, representative_claim, reset, fallback_percentage (aggregate)
   - status_5h, utilization_5h, reset_5h (5h window)
   - status_7d, utilization_7d, reset_7d (7d window)
   - overage_status, overage_disabled_reason, overage_reset (overage)
   - Numeric strings → numbers; missing fields → null (not 0 or "unknown")

C. _refreshAccessToken() — uses Node.js built-in https (no fetch/3rd-party deps).
   Shared backoff state via quotaProbeState. Max one refresh per backoff window.

D. _probeOnce() — uses Node.js built-in https. 15s timeout. Drains + discards body.

E. _readProviderConfig() — reads ~/.olp/config.json providers.<name> block.
   OLP_HOME respected (same as lib/keys.mjs). Never throws; returns {} on error.

F. doctorChecks() — new anthropic.quota_probe_reachable check (ADR 0013 Rule 6):
   - status: ok when probe disabled (returns advisory message)
   - status: ok when probe succeeds (shows utilization %)
   - status: warn when stale cache exists (probe failed but cache present)
   - status: fail when no cache + probe failed (fix_commands + human_steps recipe)

G. docs/v1x-roadmap.md — #8 Dashboard enrichment entry (D79 follow-up) added.

## Tests

- All 720 existing tests pass (npm test).
- Suite 33j updated to include anthropic.quota_probe_reachable in the expected
  probe set (3 probes total, previously 2).
- D83 (Suite 38) will add quota-probe unit tests with mock HTTP server.

## What NOT changed

- dashboard.html — untouched (D82)
- lib/audit-query.mjs — untouched (D81)
- lib/providers/codex.mjs, mistral.mjs — untouched (D84 NO-GO per ADR 0012 Amendment 1)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-26 16:51:01 +10:00
committed by GitHub
co-authored by taodeng Claude Sonnet 4.6
parent 187e79321f
commit 82d2e1cbea
3 changed files with 480 additions and 17 deletions
+18
View File
@@ -88,6 +88,24 @@
- **Tracking.** Not a GitHub issue. Tracked here. - **Tracking.** Not a GitHub issue. Tracked here.
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale. - **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
## #8 — Dashboard enrichment: per-provider subscription quota + reset times + 1-min refresh + manual refresh (D78 follow-up)
- **What.** Phase 3 dashboard (D51 `dashboard.html`, v0.3.0) shows: per-provider quota (currently always "n/a — no quota api"), last-24h request count + cache hit + fallback rate, 30d request-count sparkline, top fallback chains. **Maintainer request 2026-05-26 post-D78**: extend to show what each enabled provider's subscription is actually consuming, with reset times visible, refresh once per minute (current 30s is OK but maintainer specified 1min target), and a manual refresh button. Reference design: Claude.ai's own `claude.ai/settings/usage` page — current session bar with "Resets in 1hr 6min", weekly all-models bar with "Resets Sun 9:00 PM", per-model bar (Sonnet only), additional features (routine runs), usage credits + monthly spend limit + auto-reload toggle.
- **Why deferred.** v0.3.0/v0.4.x ships the dashboard frame but `provider.quotaStatus()` returns `null` in all three v0.1 plugins (anthropic / openai / mistral). The ratifying spec in ADR 0004 Amendment 2 punts `quotaStatus()` to v1.x ("soft trigger reactivation") — this dashboard ask is the **operator-facing reason** that work would land.
- **What this requires.** Per-provider plugin work + dashboard.html UI work + audit-query.mjs aggregation:
1. **`lib/providers/anthropic.mjs quotaStatus()`** — discover where the maintainer's Claude.ai subscription quota state is exposed. Candidates: (a) `claude` CLI command (e.g., `claude usage`) if Anthropic adds one — currently absent; (b) parsing the `claude-code` output for rate-limit error messages and caching state from headers; (c) hitting `api.anthropic.com/v1/.../usage` directly via the OAuth refresh token — not a documented endpoint, primary-source risk. ADR 0002 Rule 1 / Rule 5 require an authority citation before any implementation. Likely path: **wait until Anthropic publishes a documented endpoint**, OR derive from audit-side request counts only (no real quota truth, just "you sent N requests in the current 5h window").
2. **`lib/providers/openai.mjs quotaStatus()`** — codex CLI doesn't expose ChatGPT-subscription quota state. OpenAI rate-limit headers per request might be parseable but ADR 0004 Amendment 2 explicitly says no plugin parses HTTP status at v0.1.
3. **`lib/providers/mistral.mjs quotaStatus()`** — Le Chat Pro has `/v1/usage` endpoint per Mistral docs (verify).
4. **`dashboard.html` UI restructure** to a Claude.ai-style layout: rows of (label, bar, "Resets in X" / "Resets at <day-of-week> <time>", percent). Add a manual refresh button + change auto-poll from 30s → 60s. Optionally a usage-credits / per-key spend display if Phase 5 ships per-key cost weights.
5. **`lib/audit-query.mjs`** — extend `aggregateRequests` / `spendTrendDaily` to compute "in the current rolling window" (since session/week start) per provider. Today's aggregates are wall-clock windows; subscription resets are per-account-anchored. Need a way to model session windows (e.g., "Anthropic 5h-from-first-request-since-last-reset").
- **Reference (maintainer 2026-05-26).** Screenshot of `claude.ai/settings/usage` shared inline. Key panels: Plan usage limits (current session + resets-in), Weekly limits (All models / Sonnet only / per-feature breakdown, each with resets-on), Additional features (Daily included routine runs N / 15), Usage credits (toggle + spent vs monthly limit + auto-reload + buy-credits link).
- **Tracking.** Not yet a GitHub issue. Track here + cross-reference ADR 0004 Amendment 2 (soft trigger reactivation — same `quotaStatus()` data-source work) when this becomes Phase 5 scope.
- **Code anchors today.**
- `dashboard.html` — current 4 panels; needs restructure to Claude.ai-style row layout
- `lib/providers/anthropic.mjs` / `openai.mjs` / `mistral.mjs``quotaStatus()` returns null today
- `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)
- **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. - **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.
+450 -12
View File
@@ -49,10 +49,17 @@
* If a multi-turn conversation includes tool-call history, the textual content is * If a multi-turn conversation includes tool-call history, the textual content is
* preserved but the structured call metadata is lost. * preserved but the structured call metadata is lost.
* *
* Quota status: null at D4. Anthropic does not expose a programmatic quota endpoint. * Quota status: D80 (Phase 5) — live plan-usage probe via POST /v1/messages.
* TODO(2026-06-16): one-shot audit per ALIGNMENT.md § One-shot Triggered Audits. * Authority: ALIGNMENT.md Class-specific Exception §1 + ADR 0002 Amendment 8
* After 2026-06-15 Agent SDK Credit billing split takes effect, re-evaluate whether * (direct-API READ-ONLY exemption, three constraints: READ-ONLY, subscription-scope,
* a programmatic credit-pool balance API exists and pin it here if found. * idempotent-failure) + ADR 0013 (OAuth READ-ONLY consumption rules) +
* ADR 0012 D80 (Phase 5 charter).
* Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
* Port source: OCP server.mjs:842-1109.
* All 13 anthropic-ratelimit-unified-* fields parsed (including 3 fields new since
* OCP's 2026-04 capture: 5h-status, 7d-status, overage-reset).
* Opt-in: ~/.olp/config.json providers.anthropic.quota_probe_enabled must be true.
* Cache TTL: 5min. Refresh backoff: 60s3600s exponential.
*/ */
import { spawn as defaultSpawn } from 'node:child_process'; import { spawn as defaultSpawn } from 'node:child_process';
@@ -60,6 +67,7 @@ import { execFileSync, execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import * as https from 'node:https';
import { ProviderError } from './base.mjs'; import { ProviderError } from './base.mjs';
// ── Binary resolution ───────────────────────────────────────────────────── // ── Binary resolution ─────────────────────────────────────────────────────
@@ -118,6 +126,282 @@ export function readAuthArtifact() {
return null; return null;
} }
// ── Quota probe constants + state ────────────────────────────────────────
// Port of OCP server.mjs:852-862 — module-level cache + backoff state.
// ADR 0013 Rule 3: 5min TTL, 60s-3600s exponential backoff.
//
// Authority: ADR 0002 Amendment 8 + ADR 0013 + ADR 0012 D80 + audit memory
// ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
// Port source: OCP server.mjs:842-1109
//
// OAuth client_id: Claude Code's IdP-registered ID (per audit memory § OAuth refresh).
// Configurable via CLAUDE_CODE_OAUTH_CLIENT_ID env var (binary supports this override
// per `strings` output 2026-05-26).
const QUOTA_OAUTH_CLIENT_ID =
process.env.CLAUDE_CODE_OAUTH_CLIENT_ID ?? '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const QUOTA_OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
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)
const QUOTA_SCHEMA_VERSION = '2026-05-26';
// Module-level probe state — one instance per process (not per request).
// Ported from OCP server.mjs:852 (usageCache) + 862 (oauthRefreshBackoff).
const quotaProbeState = {
cache: null, // { fetchedAt: <ms>, data: <quotaShape> } when fresh; null when not
backoffUntil: 0, // ms epoch: when next refresh attempt is allowed
backoffMs: QUOTA_BACKOFF_MIN_MS, // current backoff window; doubled on failure, reset on success
};
// ── Config reader: providers.<name>.<field> ───────────────────────────────
// Reads ~/.olp/config.json and returns providers.<name> config object.
// Never throws. Returns {} on any error.
// OLP_HOME env respected (same as lib/keys.mjs _resolveOlpHome).
function _readProviderConfig(providerName) {
try {
const olpHome = process.env.OLP_HOME ?? join(homedir(), '.olp');
const cfgPath = join(olpHome, 'config.json');
if (!existsSync(cfgPath)) return {};
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
if (cfg && typeof cfg === 'object' && cfg.providers && typeof cfg.providers === 'object') {
const p = cfg.providers[providerName];
return (p && typeof p === 'object') ? p : {};
}
return {};
} catch {
return {};
}
}
// ── Parse anthropic-ratelimit-unified-* headers (all 13 fields) ───────────
// ADR 0013 Rule 2: body discarded; only headers parsed.
// Schema: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
// Port of OCP server.mjs:1013-1082 (parseRateLimitHeaders) — OLP version parses
// all 13 fields (OCP parsed 9; 3 new: 5h-status, 7d-status, overage-reset).
//
// Returns the canonical 13-field shape. Missing fields → null (not 0 or "unknown")
// so consumers can detect "field not present" vs "field present but zero".
// Exception: numeric string fields → number; "overage-reset" only fires on active
// overage per audit memory so null is the correct default.
function _parseRateLimitHeaders(headers) {
// helpers
const str = (k) => {
const v = headers[k.toLowerCase()];
return (v !== undefined && v !== null) ? String(v) : null;
};
const num = (k) => {
const v = str(k);
if (v === null) return null;
const n = parseFloat(v);
return isNaN(n) ? null : n;
};
const int = (k) => {
const v = str(k);
if (v === null) return null;
const n = parseInt(v, 10);
return isNaN(n) ? null : n;
};
return {
// Overall / representative
status: str('anthropic-ratelimit-unified-status'),
representative_claim: str('anthropic-ratelimit-unified-representative-claim'),
reset: int('anthropic-ratelimit-unified-reset'),
fallback_percentage: num('anthropic-ratelimit-unified-fallback-percentage'),
// 5-hour window
status_5h: str('anthropic-ratelimit-unified-5h-status'), // NEW vs OCP 2026-04
utilization_5h: num('anthropic-ratelimit-unified-5h-utilization'),
reset_5h: int('anthropic-ratelimit-unified-5h-reset'),
// 7-day window
status_7d: str('anthropic-ratelimit-unified-7d-status'), // NEW vs OCP 2026-04
utilization_7d: num('anthropic-ratelimit-unified-7d-utilization'),
reset_7d: int('anthropic-ratelimit-unified-7d-reset'),
// Overage
overage_status: str('anthropic-ratelimit-unified-overage-status'),
overage_disabled_reason: str('anthropic-ratelimit-unified-overage-disabled-reason'),
overage_reset: int('anthropic-ratelimit-unified-overage-reset'), // NEW vs OCP 2026-04
};
}
// ── OAuth token refresh ───────────────────────────────────────────────────
// ADR 0013 Rule 3: max one refresh per backoff window; backoff 60s→3600s.
// Port of OCP server.mjs:890-941 (refreshOAuthToken) — adapted for Node.js
// built-in https (no fetch dependency) + shared quotaProbeState backoff.
//
// Returns new access_token string on success, null on failure.
// NEVER throws (idempotent-failure per ADR 0002 Amendment 8 constraint 3).
async function _refreshAccessToken(refreshToken) {
const now = Date.now();
if (now < quotaProbeState.backoffUntil) {
return null; // still in backoff window
}
return new Promise((resolve) => {
const body = JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: QUOTA_OAUTH_CLIENT_ID,
scope: 'user:inference user:profile',
});
const url = new URL(QUOTA_OAUTH_TOKEN_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let raw = '';
res.on('data', (chunk) => { raw += chunk; });
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const data = JSON.parse(raw);
// Reset backoff on success
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
quotaProbeState.backoffUntil = 0;
resolve(data.access_token ?? null);
} catch {
_scheduleBackoff();
resolve(null);
}
} else {
_scheduleBackoff();
resolve(null);
}
});
});
req.on('error', () => {
_scheduleBackoff();
resolve(null);
});
req.setTimeout(10_000, () => {
req.destroy();
_scheduleBackoff();
resolve(null);
});
req.write(body);
req.end();
});
}
// Helper: advance backoff exponentially (doubles currentMs, caps at MAX).
function _scheduleBackoff() {
quotaProbeState.backoffUntil = Date.now() + quotaProbeState.backoffMs;
quotaProbeState.backoffMs = Math.min(quotaProbeState.backoffMs * 2, QUOTA_BACKOFF_MAX_MS);
}
// ── Probe: POST /v1/messages, parse response headers ─────────────────────
// ADR 0013 Rule 2: READ-ONLY, max_tokens:1, body discarded, headers-only.
// Only permitted endpoint: POST /v1/messages (ADR 0013 Rule 2 per-endpoint containment).
// NOT: /api/oauth/usage (hallucinated endpoint — see alignment.yml blacklist).
//
// Port of OCP server.mjs:943-1011 (fetchUsageFromApi) using Node.js built-in https
// (no third-party fetch wrapper — consistent with server.mjs zero-external-deps policy).
//
// Returns { fields: {...13}, raw: {...headers} } on success.
// Returns null on any failure (idempotent-failure per ADR 0002 Amendment 8 §3).
async function _probeOnce(creds) {
const { accessToken, refreshToken, expiresAt } = creds;
let token = accessToken;
// Pre-emptive refresh if token looks expired (5min buffer, same as Claude Code).
if (expiresAt && Date.now() + 300_000 >= expiresAt && refreshToken) {
const newToken = await _refreshAccessToken(refreshToken);
if (newToken) token = newToken;
}
const body = JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1,
messages: [{ role: 'user', content: '.' }],
});
const doRequest = (bearerToken) => new Promise((resolve, reject) => {
const url = new URL(QUOTA_API_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'oauth-2025-04-20',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
// Drain and discard the response body — ADR 0013 Rule 2: headers-only.
res.on('data', () => {});
res.on('end', () => {
// Collect all response headers as a plain object (lowercased keys).
const hdrs = {};
for (const [k, v] of Object.entries(res.headers)) {
hdrs[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v;
}
resolve({ statusCode: res.statusCode, headers: hdrs });
});
});
req.on('error', reject);
req.setTimeout(15_000, () => {
req.destroy(new Error('probe timeout'));
});
req.write(body);
req.end();
});
try {
let result = await doRequest(token);
// 401/403 → try single refresh-and-retry (port of OCP server.mjs:984-990)
if ((result.statusCode === 401 || result.statusCode === 403) && refreshToken) {
const newToken = await _refreshAccessToken(refreshToken);
if (newToken) {
token = newToken;
result = await doRequest(token);
}
}
const { statusCode, headers } = result;
// Extract ratelimit headers from the response
const rlHeaders = {};
for (const [k, v] of Object.entries(headers)) {
if (k.startsWith('anthropic-ratelimit')) {
rlHeaders[k] = v;
}
}
// Non-2xx AND no ratelimit headers → treat as failure
if (statusCode >= 400 && Object.keys(rlHeaders).length === 0) {
// Schedule backoff on failure
_scheduleBackoff();
return null;
}
// Success (or non-2xx with headers present — headers still valid quota data)
// Reset backoff on successful probe
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
quotaProbeState.backoffUntil = 0;
const fields = _parseRateLimitHeaders(rlHeaders);
return { fields, raw: rlHeaders };
} catch {
_scheduleBackoff();
return null;
}
}
// ── IR → prompt text serialization ─────────────────────────────────────── // ── IR → prompt text serialization ───────────────────────────────────────
// OCP server.mjs:422-468 (messagesToPrompt) — no session management in OLP; // OCP server.mjs:422-468 (messagesToPrompt) — no session management in OLP;
// we always pass the full serialized messages as stdin to `claude -p`. // we always pass the full serialized messages as stdin to `claude -p`.
@@ -437,12 +721,91 @@ export function estimateCost(request) {
} }
// ── quotaStatus ─────────────────────────────────────────────────────────── // ── quotaStatus ───────────────────────────────────────────────────────────
// Returns null at D4. Anthropic does not expose a programmatic quota endpoint. // D80 (Phase 5) — live plan-usage probe.
// TODO(2026-06-16 one-shot audit per ALIGNMENT.md § One-shot Triggered Audits): //
// After the 2026-06-15 Agent SDK Credit billing-split takes effect, check whether // Authority: ALIGNMENT.md Class-specific Exception §1
// the Agent SDK Credit pool exposes a balance/usage API endpoint. If it does, // ADR 0002 Amendment 8 (READ-ONLY / subscription-scope / idempotent-failure)
// implement here and update this comment with the endpoint URL + version pin. // ADR 0013 (credential reuse, READ-ONLY wire, cache + backoff, opt-in, failure transparency)
// ADR 0012 D80 (Phase 5 charter)
// Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
// Port source: OCP server.mjs:842-1109 (usageCache, refreshOAuthToken, fetchUsageFromApi,
// parseRateLimitHeaders, handleUsage)
//
// ADR 0013 Rule 4 (opt-in): returns null unless config.providers.anthropic.quota_probe_enabled
// is explicitly true in ~/.olp/config.json.
//
// Return shape (when enabled + successful):
// {
// probedAt: <unix-ms>,
// source: 'anthropic-ratelimit-unified-headers',
// schemaVersion: '2026-05-26',
// stale: false,
// fields: { ...13 fields... },
// raw: { ...response-headers-object... }
// }
// Returns null when: disabled, no credentials, probe failure with no stale cache.
// Returns { ...shape, stale: true, last_fresh_at } when probe failed but cache exists.
export async function quotaStatus(_authContext) { export async function quotaStatus(_authContext) {
// ADR 0013 Rule 4 — opt-in check
const providerCfg = _readProviderConfig('anthropic');
if (providerCfg.quota_probe_enabled !== true) {
return null;
}
const now = Date.now();
// Cache hit: return cached value if within TTL (ADR 0013 Rule 3)
if (quotaProbeState.cache !== null &&
(now - quotaProbeState.cache.fetchedAt) < QUOTA_CACHE_TTL_MS) {
return quotaProbeState.cache.data;
}
// Backoff check: still in exponential backoff window — return stale cache or null
if (now < quotaProbeState.backoffUntil) {
if (quotaProbeState.cache !== null) {
// Return stale cache marked as stale (ADR 0013 Rule 3)
return {
...quotaProbeState.cache.data,
stale: true,
last_fresh_at: quotaProbeState.cache.fetchedAt,
};
}
return null; // no stale cache
}
// Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path)
const creds = readAuthArtifact();
if (!creds?.accessToken) {
// No credentials at all — return stale cache or null; do not hammer API with 401s
return null;
}
// Perform the probe
const probeResult = await _probeOnce(creds);
if (probeResult !== null) {
// Success: cache the result, reset backoff
const shape = {
probedAt: now,
source: 'anthropic-ratelimit-unified-headers',
schemaVersion: QUOTA_SCHEMA_VERSION,
stale: false,
fields: probeResult.fields,
raw: probeResult.raw,
};
quotaProbeState.cache = { fetchedAt: now, data: shape };
return shape;
}
// Probe failed: _probeOnce already called _scheduleBackoff().
// Return stale cache if present, else null.
if (quotaProbeState.cache !== null) {
return {
...quotaProbeState.cache.data,
stale: true,
last_fresh_at: quotaProbeState.cache.fetchedAt,
};
}
return null; return null;
} }
@@ -485,7 +848,7 @@ function _defaultBinaryExists() {
} }
} }
// ── doctorChecks (ADR 0002 Amendment 7, D67) ────────────────────────────── // ── doctorChecks (ADR 0002 Amendment 7, D67; quota probe check D80) ──────
// Per-plugin probe templates consumed by `olp doctor`. Each check returns // Per-plugin probe templates consumed by `olp doctor`. Each check returns
// { status, message, evidence? } where evidence.fix_commands[] flow into the // { status, message, evidence? } where evidence.fix_commands[] flow into the
// next_action.ai_executable[] block and evidence.human_steps[] into // next_action.ai_executable[] block and evidence.human_steps[] into
@@ -494,9 +857,12 @@ function _defaultBinaryExists() {
// Probes: // Probes:
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN) // anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken // anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken
// anthropic.quota_probe_reachable — D80 (Phase 5): probe reachable check when quota_probe_enabled.
// Only runs if quota_probe_enabled: true in config.
// ADR 0013 Rule 6 + ADR 0002 Amendment 8.
// //
// Both probes share the existing test seams (binaryExists / readAuthArtifact) so the // The first two probes share the existing test seams (binaryExists / readAuthArtifact)
// suite can stub them deterministically without spawning the real binary. // so the suite can stub them deterministically without spawning the real binary.
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) { export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact; const authRead = _authReadFn ?? readAuthArtifact;
@@ -540,6 +906,78 @@ export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
}; };
}, },
}, },
{
id: 'anthropic.quota_probe_reachable',
category: 'provider',
// D80 / ADR 0013 Rule 6 — failure transparency for olp doctor.
// Only runs if quota_probe_enabled: true in ~/.olp/config.json.
// Status mapping:
// ok → probe returned fresh data
// warn → stale cache returned (probe failed but cache present)
// fail → no cache AND probe failed; fix_commands recipe provided
async run() {
const providerCfg = _readProviderConfig('anthropic');
if (providerCfg.quota_probe_enabled !== true) {
return { status: 'ok', message: 'quota probe disabled (opt-in via config.providers.anthropic.quota_probe_enabled)' };
}
// Attempt a fresh probe (bypassing module-level cache for doctor diagnostics)
const auth = authRead();
if (!auth?.accessToken) {
return {
status: 'fail',
message: 'quota probe enabled but no OAuth credential — probe cannot run',
evidence: {
human_steps: [
'run: claude (first interactive launch prompts browser OAuth login)',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
},
};
}
const result = await _probeOnce(auth);
if (result !== null) {
const util5h = result.fields.utilization_5h;
const util7d = result.fields.utilization_7d;
const msg = `quota probe OK — 5h utilization: ${util5h !== null ? `${Math.round(util5h * 100)}%` : 'n/a'}, 7d utilization: ${util7d !== null ? `${Math.round(util7d * 100)}%` : 'n/a'}`;
return { status: 'ok', message: msg };
}
// Probe failed — check if stale cache exists
if (quotaProbeState.cache !== null) {
const ageMin = Math.round((Date.now() - quotaProbeState.cache.fetchedAt) / 60_000);
return {
status: 'warn',
message: `quota probe failed (returning stale cache from ${ageMin}min ago); check network or token expiry`,
evidence: {
fix_commands: [
'olp doctor # re-run after verifying OAuth credentials are valid',
],
human_steps: [
'run: claude (if token may have expired, re-login)',
],
},
};
}
// No cache at all — hard failure
return {
status: 'fail',
message: 'quota probe failed and no cached data available; check OAuth credentials and network access to api.anthropic.com',
evidence: {
fix_commands: [
'node -e "const { readAuthArtifact } = await import(\'./lib/providers/anthropic.mjs\'); console.log(JSON.stringify(readAuthArtifact()?.accessToken ? \'token_present\' : \'no_token\'))"',
],
human_steps: [
'verify network access: curl -s -o /dev/null -w "%{http_code}" https://api.anthropic.com/v1/messages',
'if OAuth token is expired: run: claude (re-login)',
'to disable probe: set providers.anthropic.quota_probe_enabled = false in ~/.olp/config.json',
],
},
};
},
},
]; ];
} }
+10 -3
View File
@@ -14002,12 +14002,19 @@ describe('Suite 33 — D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67
}); });
it('33j — anthropic plugin doctorChecks() returns the documented probe set', () => { it('33j — anthropic plugin doctorChecks() returns the documented probe set', () => {
// Plugin contract: ADR 0002 Amendment 7. anthropic.mjs ships // Plugin contract: ADR 0002 Amendment 7 (D67) + D80 (quota probe check).
// anthropic.cli_available + anthropic.oauth_token_present. // anthropic.mjs ships:
// anthropic.cli_available (D67)
// anthropic.oauth_token_present (D67)
// anthropic.quota_probe_reachable (D80 — Phase 5, ADR 0013 Rule 6)
const checks = anthropic.doctorChecks(); const checks = anthropic.doctorChecks();
assert.ok(Array.isArray(checks)); assert.ok(Array.isArray(checks));
const ids = checks.map(c => c.id).sort(); const ids = checks.map(c => c.id).sort();
assert.deepEqual(ids, ['anthropic.cli_available', 'anthropic.oauth_token_present']); assert.deepEqual(ids, [
'anthropic.cli_available',
'anthropic.oauth_token_present',
'anthropic.quota_probe_reachable',
]);
assert.ok(checks.every(c => c.category === 'provider')); assert.ok(checks.every(c => c.category === 'provider'));
assert.ok(checks.every(c => typeof c.run === 'function')); assert.ok(checks.every(c => typeof c.run === 'function'));
}); });