mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
@@ -88,6 +88,24 @@
|
||||
- **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.
|
||||
|
||||
## #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)
|
||||
|
||||
- **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.
|
||||
|
||||
+452
-14
@@ -49,10 +49,17 @@
|
||||
* If a multi-turn conversation includes tool-call history, the textual content is
|
||||
* preserved but the structured call metadata is lost.
|
||||
*
|
||||
* Quota status: null at D4. Anthropic does not expose a programmatic quota endpoint.
|
||||
* TODO(2026-06-16): one-shot audit per ALIGNMENT.md § One-shot Triggered Audits.
|
||||
* After 2026-06-15 Agent SDK Credit billing split takes effect, re-evaluate whether
|
||||
* a programmatic credit-pool balance API exists and pin it here if found.
|
||||
* Quota status: D80 (Phase 5) — live plan-usage probe via POST /v1/messages.
|
||||
* Authority: ALIGNMENT.md Class-specific Exception §1 + ADR 0002 Amendment 8
|
||||
* (direct-API READ-ONLY exemption, three constraints: READ-ONLY, subscription-scope,
|
||||
* 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: 60s–3600s exponential.
|
||||
*/
|
||||
|
||||
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 { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import { ProviderError } from './base.mjs';
|
||||
|
||||
// ── Binary resolution ─────────────────────────────────────────────────────
|
||||
@@ -118,6 +126,282 @@ export function readAuthArtifact() {
|
||||
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 ───────────────────────────────────────
|
||||
// OCP server.mjs:422-468 (messagesToPrompt) — no session management in OLP;
|
||||
// we always pass the full serialized messages as stdin to `claude -p`.
|
||||
@@ -437,12 +721,91 @@ export function estimateCost(request) {
|
||||
}
|
||||
|
||||
// ── quotaStatus ───────────────────────────────────────────────────────────
|
||||
// Returns null at D4. Anthropic does not expose a programmatic quota endpoint.
|
||||
// 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
|
||||
// the Agent SDK Credit pool exposes a balance/usage API endpoint. If it does,
|
||||
// implement here and update this comment with the endpoint URL + version pin.
|
||||
// D80 (Phase 5) — live plan-usage probe.
|
||||
//
|
||||
// Authority: ALIGNMENT.md Class-specific Exception §1
|
||||
// ADR 0002 Amendment 8 (READ-ONLY / subscription-scope / idempotent-failure)
|
||||
// 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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -485,18 +848,21 @@ 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
|
||||
// { status, message, evidence? } where evidence.fix_commands[] flow into the
|
||||
// next_action.ai_executable[] block and evidence.human_steps[] into
|
||||
// next_action.human_required[].
|
||||
//
|
||||
// Probes:
|
||||
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
|
||||
// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
|
||||
// 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
|
||||
// suite can stub them deterministically without spawning the real binary.
|
||||
// The first two probes share the existing test seams (binaryExists / readAuthArtifact)
|
||||
// so the suite can stub them deterministically without spawning the real binary.
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
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
@@ -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', () => {
|
||||
// Plugin contract: ADR 0002 Amendment 7. anthropic.mjs ships
|
||||
// anthropic.cli_available + anthropic.oauth_token_present.
|
||||
// Plugin contract: ADR 0002 Amendment 7 (D67) + D80 (quota probe check).
|
||||
// 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();
|
||||
assert.ok(Array.isArray(checks));
|
||||
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 => typeof c.run === 'function'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user