Files
olp/lib/providers/anthropic.mjs
T
bddf2cba1e release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) (#58)
* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review)

Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on
PR #57, reproduced with local mocks):

F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3)
  `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing
  `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff
  window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL
  consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified:
  "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through
  `quotaStatus()` and MUST NOT call `_probeOnce()` directly."

F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5)
  A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live).
  Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset +
  7d-utilization + 7d-reset present; absence → `failureKind: 'schema_drift'`, backoff
  scheduled, result NOT cached. ADR 0013 Rule 5 updated with the gate specification.

F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6)
  `aggregateProviderQuota()` collapsed all failure modes into `status: 'unavailable'`
  ("no public quota api or probe disabled") — same as providers with no API at all.
  Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return
  `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`.
  New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift |
  network | other. Dashboard renders `unreachable` with red border + failure detail.

Authority:
  ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency)
  ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status)
  ADR 0002 Amendment 8 unchanged (constitutional permission for the probe)
  Codex review findings F1–F3 (codex on PR #57)

Changes:
  - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind;
    _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract
    (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes
    through quotaStatus(); reset functions updated
  - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field;
    aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status
  - dashboard.html: unreachable CSS classes + render path + footer v0.5.1
  - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1;
    38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests
  - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract)
  - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus);
    Rule 5 min-viable-schema gate specification
  - package.json: 0.5.0 → 0.5.1
  - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased
  - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum)

Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking,
4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes).
Deferring #3 (test-only edge case in module-level seam restore) and #4
(pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy
quota shape; separate PR planned).

Nit #1 — ADR 0002 Amendment 8 documentation drift.

Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather
than throwing", and line 33 said "stale-cache-on-failure (`null` is
returned only when no cache entry exists; if a stale entry exists it's
returned with a `stale: true` marker)". v0.5.1 refined this contract:
`null` is now reserved STRICTLY for opt-in-off, and all failure modes
return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`.

The substantive idempotent-failure constraint (no throw to caller) is
unchanged. The operational description in Amendment 8 was stale — fixed
to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the
v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as
the regression coverage producing the new shape.

Nit #2 — `failureKind: 'opt_in_off'` declared but never produced.

The enum value was listed in both the code comment (anthropic.mjs:250)
and ADR 0008 Amendment 2 (line 30) but never actually assigned —
because when opt-in is off, `quotaStatus()` returns the literal `null`
BEFORE any state mutation happens. The enum value was dead.

Fix: removed `opt_in_off` from both enum declarations + added an
inline note explaining that consumers (audit-query, doctor) distinguish
opt-in-off by checking `quotaStatus() === null`, not via failureKind.

Deferred:

- Nit #3 (test-seam restore edge case): if a caller pre-sets
  `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`,
  the finally block restores to null clobbering pre-set value.
  Test-only impact, no production risk. Pure hygiene; defer.

- Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js
  still read legacy `body.quota` field, never consume `quota_v2`.
  Reviewer confirmed neither crashes — both gracefully fall through
  to "no quota api" branch. Out of scope for this hotfix per the
  hotfix dispatch contract; separate PR will migrate them.

Tests: 759/759 still pass post-fold-in. No test changes needed.

Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency)
+ ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:33:47 +10:00

1277 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* lib/providers/anthropic.mjs — Anthropic Claude provider plugin
*
* Authority: OLP ALIGNMENT.md § Authority 1 — anthropic plugin governed by
* `claude -p` from `@anthropic-ai/claude-code`.
* OCP server.mjs inherits cli.js 2.1.89 audit pin at fork (per ALIGNMENT.md
* § Provider authority pins).
* D26 round-3 F18 observation: @anthropic-ai/claude-code v2.1.89 confirmed
* present at D4 implementation (captured at D4 implementation per ALIGNMENT.md
* Rule 5 — the circular ALIGNMENT.md ↔ plugin header citation is resolved by
* this in-plugin record of the OLP-side observation).
* D36 #15: See docs/provider-audits/anthropic.md for the version-capture
* artifact (single living document — captured 2026-05-24; re-capture at every
* plugin touch or annual audit). The artifact records the live `claude --version`
* today (v2.1.132) versus the plugin pin (v2.1.89, D4) and verifies that the
* load-bearing flags (-p, --output-format, --no-session-persistence, --model,
* --debug) are all still present and semantically unchanged in the current binary.
*
* Spawn pattern ported from:
* OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence)
* OCP server.mjs:480-607 (spawnClaudeProcess — env cleanup, spawn, stdin write)
* OCP server.mjs:422-468 (messagesToPrompt — text serialization)
*
* Auth reading ported from:
* OCP server.mjs:864-888 (getOAuthCredentials — env → .credentials.json → keychain)
* OCP server.mjs:531-534 (env cleanup: delete ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL,
* ANTHROPIC_AUTH_TOKEN before spawn)
*
* Contract version: 1.0 (ADR 0002 § "Provider contract (v1.0 interface)" + D4 contractVersion fold-in)
*
* Status at D4: CANDIDATE. Not yet Enabled per ALIGNMENT.md § Provider Inventory.
* The plugin is present in STATIC_REGISTRY but enabled: false is the default config.
* POST /v1/chat/completions continues to return 503 until D5 flips the enabled flag
* in ~/.olp/config.json after E2E audit passes.
*
* Lossy translations (per ADR 0003 § "Lossy-translation documentation requirement"):
* - `response_format: json_object` — Anthropic `claude -p` does not natively honor this
* field; a system-prompt augmentation is injected ("Reply with valid JSON only.").
* - `top_p` — not mapped to a `claude -p` flag; dropped silently. `claude -p` does not
* expose --top-p.
* - `tool_choice: "required"` — not a `claude -p` flag; dropped.
* - Request-level `tools[]` — `claude -p --output-format text` is a text-in/text-out
* CLI and does not consume structured tool definitions on the wire. The array is
* silently dropped; if a caller wants tool-use they should pre-prompt the model
* about the tools in the system message. ALIGNMENT.md Rule 2 forbids inventing
* a wire format Anthropic's CLI does not accept.
* - Assistant-message `tool_calls` and `tool_call_id` (IR messages representing prior
* turns where the assistant invoked a tool) — same reason as above; dropped.
* If a multi-turn conversation includes tool-call history, the textual content is
* preserved but the structured call metadata is lost.
*
* 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: 60s3600s exponential.
*/
import { spawn as defaultSpawn } from 'node:child_process';
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 * as http from 'node:http';
import { ProviderError } from './base.mjs';
// ── Binary resolution ─────────────────────────────────────────────────────
// OLP_CLAUDE_BIN env takes priority, then falls back to 'claude' from PATH.
// Ported from OCP server.mjs:87-123 (resolveClaude) — simplified for plugin
// scope (OCP's multi-path nvm/fnm/asdf resolution is omitted; OLP plugins
// use the simpler env-override-first pattern and rely on PATH for the rest).
//
// OCP server.mjs:88: if (process.env.CLAUDE_BIN) { ... return process.env.CLAUDE_BIN; }
// OLP uses OLP_CLAUDE_BIN to avoid colliding with OCP's CLAUDE_BIN if both are
// installed on the same machine.
function resolveClaudeBin() {
return process.env.OLP_CLAUDE_BIN || 'claude';
}
// ── Auth artifact reading ─────────────────────────────────────────────────
// Ported from OCP server.mjs:864-888 (getOAuthCredentials).
// Priority order (matching OCP exactly):
// 1. CLAUDE_CODE_OAUTH_TOKEN env var — explicit override, highest precedence
// 2. ~/.claude/.credentials.json (Linux file-based path)
// 3. macOS keychain via `security find-generic-password`
// Both label formats tried: "claude-code-credentials" and "Claude Code-credentials"
//
// Returns { accessToken, refreshToken?, expiresAt? } or null.
// Throws nothing — null means "not found, try next".
export function readAuthArtifact() {
// 1. Env var — OCP server.mjs:866-868
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return { accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN };
}
// 2. Linux/cross-platform file-based credentials — OCP server.mjs:871-875
// Note: OCP uses ~/.claude/.credentials.json (dot-prefix), not ~/.claude/credentials.json.
// This matches the actual path Claude Code writes to.
try {
const credPath = join(homedir(), '.claude', '.credentials.json');
const raw = readFileSync(credPath, 'utf8');
const creds = JSON.parse(raw);
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
} catch { /* fall through */ }
// 3. macOS keychain — OCP server.mjs:877-885
// Only attempted on darwin to avoid shell-out overhead on other platforms.
if (process.platform === 'darwin') {
for (const label of ['claude-code-credentials', 'Claude Code-credentials']) {
try {
const raw = execFileSync('security', [
'find-generic-password', '-s', label, '-w',
], { encoding: 'utf8', timeout: 5000 }).trim();
const creds = JSON.parse(raw);
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
} catch { /* try next */ }
}
}
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_DEFAULT = 'https://platform.claude.com/v1/oauth/token';
const QUOTA_API_URL_DEFAULT = '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';
// ── Test seams for quota probe (D83) ─────────────────────────────────────
// These variables allow tests to redirect HTTP requests to a local mock server
// without modifying production logic. In production, they always hold the
// DEFAULT values. _setQuotaUrlsForTest() / _resetQuotaProbeStateForTest() are
// the ONLY permitted mutations — production code must never call them.
//
// The _quotaHttpMod seam switches from https to http so test mock servers can
// be plain HTTP servers on ephemeral ports (no TLS cert management in tests).
//
// ADR 0012 D83: test seam discipline — injected via export, not monkey-patch.
let _quotaApiUrl = QUOTA_API_URL_DEFAULT;
let _quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT;
let _quotaHttpMod = https; // switched to http by _setQuotaUrlsForTest when protocol is http:
export function _setQuotaUrlsForTest(apiUrl, oauthUrl) {
_quotaApiUrl = apiUrl;
_quotaOauthTokenUrl = oauthUrl ?? _quotaOauthTokenUrl;
// Auto-detect protocol: if test URL is http, use http module (no TLS)
const proto = new URL(apiUrl).protocol;
_quotaHttpMod = proto === 'http:' ? http : https;
}
export function _resetQuotaProbeStateForTest() {
// Resets probe runtime state (cache + backoff), restores production URLs,
// and clears the auth-read override. Call in test finally blocks.
_quotaApiUrl = QUOTA_API_URL_DEFAULT;
_quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT;
_quotaHttpMod = https;
_quotaAuthReadFnForTest = null;
quotaProbeState.cache = null;
quotaProbeState.backoffUntil = 0;
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
// v0.5.1 (F3): reset failure-tracking fields
quotaProbeState.lastError = null;
quotaProbeState.failureKind = null;
}
// Convenience: reset state only, leaving URL overrides in place.
// Useful when a test sets URLs first, then wants a clean cache/backoff.
export function _resetQuotaStateOnlyForTest() {
quotaProbeState.cache = null;
quotaProbeState.backoffUntil = 0;
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
// v0.5.1 (F3): reset failure-tracking fields
quotaProbeState.lastError = null;
quotaProbeState.failureKind = null;
}
// Returns a direct reference to the internal quotaProbeState object.
// Tests MUST NOT store this reference across calls — the object is stable
// but its properties are mutated by the probe machinery. Direct mutation
// (e.g. state.backoffUntil = 0) is safe ONLY in test contexts where a
// controlled probe sequence is being driven. Production code never mutates
// this object from outside this module.
export function _getQuotaProbeStateForTest() {
return quotaProbeState;
}
// Optional override for readAuthArtifact used by quotaStatus() and doctorChecks().
// When non-null, replaces readAuthArtifact() for probe calls. Reset to null via
// _resetQuotaProbeStateForTest() or explicitly by assigning null.
// This seam prevents real credential files from interfering with unit tests.
let _quotaAuthReadFnForTest = null;
export function _setQuotaAuthReadFnForTest(fn) { _quotaAuthReadFnForTest = fn; }
/**
* 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).
// v0.5.1: added lastError + failureKind for F3 failure-transparency (ADR 0013 Rule 6).
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
// v0.5.1 (F3 — ADR 0013 Rule 6 failure transparency):
lastError: null, // { kind, message, statusCode?, attemptedAt } | null
failureKind: null, // 'no_credentials'|'auth_failed'|'rate_limited'|'schema_drift'|'network'|'other' | null
// Note: 'opt_in_off' is NOT in this enum — when the probe is opted out
// quotaStatus() returns the literal null BEFORE any state mutation, so
// failureKind is never produced for the disabled case. Consumers
// (audit-query, doctor) distinguish opt-in-off by quotaStatus() === null.
};
// ── 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(_quotaOauthTokenUrl);
const options = {
hostname: url.hostname,
port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const req = _quotaHttpMod.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(_quotaApiUrl);
const options = {
hostname: url.hostname,
port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80),
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 = _quotaHttpMod.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
// Classify failure kind for F3 (ADR 0013 Rule 6 failure transparency).
if (statusCode >= 400 && Object.keys(rlHeaders).length === 0) {
const now = Date.now();
const kind = (statusCode === 401 || statusCode === 403) ? 'auth_failed'
: statusCode === 429 ? 'rate_limited'
: 'other';
quotaProbeState.lastError = { kind, message: `HTTP ${statusCode}`, statusCode, attemptedAt: now };
quotaProbeState.failureKind = kind;
_scheduleBackoff();
return null;
}
// v0.5.1 F2 (ADR 0013 Rule 5 minimum-viable-schema gate):
// Require at least these 4 load-bearing fields (5h-utilization, 5h-reset,
// 7d-utilization, 7d-reset). A 200 OK with zero ratelimit headers (or with
// headers but missing these 4) is treated as schema-drift and classified as
// failure. The other 9 fields are tolerated as missing.
//
// Authority: ADR 0013 Rule 5 + codex review finding F2 (v0.5.1 hotfix).
const MIN_FIELDS = [
'anthropic-ratelimit-unified-5h-utilization',
'anthropic-ratelimit-unified-5h-reset',
'anthropic-ratelimit-unified-7d-utilization',
'anthropic-ratelimit-unified-7d-reset',
];
const missingMinFields = MIN_FIELDS.filter(f => rlHeaders[f] == null);
if (missingMinFields.length > 0) {
const now = Date.now();
const msg = `schema_drift: missing minimum-viable fields: ${missingMinFields.join(', ')}`;
quotaProbeState.lastError = { kind: 'schema_drift', message: msg, statusCode, attemptedAt: now };
quotaProbeState.failureKind = 'schema_drift';
_scheduleBackoff();
return null;
}
// Success (or non-2xx with headers present and passing min-field gate —
// headers still valid quota data). Reset backoff on successful probe.
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
quotaProbeState.backoffUntil = 0;
quotaProbeState.lastError = null;
quotaProbeState.failureKind = null;
const fields = _parseRateLimitHeaders(rlHeaders);
return { fields, raw: rlHeaders };
} catch (err) {
const now = Date.now();
const msg = err?.message ?? String(err);
quotaProbeState.lastError = { kind: 'network', message: msg, attemptedAt: now };
quotaProbeState.failureKind = 'network';
_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`.
//
// `claude -p` reads its prompt from stdin when no prompt argument is given.
// OCP server.mjs:542 confirms: `proc.stdin.write(prompt); proc.stdin.end();`
// The format OCP uses is: system → "[System] <text>", assistant → "[Assistant] <text>",
// user → raw text. We port the same format.
//
// ADR 0003 § Translation direction model: the plugin owns irToNative.
export function irToAnthropic(irRequest) {
const parts = [];
for (const msg of irRequest.messages) {
const text = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
if (msg.role === 'system') {
parts.push(`[System] ${text}`);
} else if (msg.role === 'assistant') {
parts.push(`[Assistant] ${text}`);
} else if (msg.role === 'tool') {
// Tool result — pass as annotated user turn so the model knows it's a tool response.
const nameAnnotation = msg.name ? ` (${msg.name})` : '';
parts.push(`[Tool Result${nameAnnotation}] ${text}`);
} else {
// user
parts.push(text);
}
}
// Lossy: response_format json_object → system-prompt augmentation.
// ADR 0003 § Lossy-translation documentation requirement.
if (irRequest.response_format?.type === 'json_object') {
parts.unshift('[System] Reply with valid JSON only. Do not include any prose outside the JSON structure.');
}
return parts.join('\n\n');
}
// ── Anthropic SSE chunk → IR ResponseChunk ────────────────────────────────
// `claude -p --output-format text` emits raw text to stdout (not NDJSON/SSE).
// This matches OCP server.mjs:735-748: each `proc.stdout.on('data', d => ...)` chunk
// is raw text content — no JSON envelope. The plugin converts raw text chunks to
// IR delta chunks, and produces a synthetic stop chunk at close.
//
// For streaming: each text chunk → IRResponseChunk { type: 'delta', content: <text> }
// For non-streaming: the full accumulated stdout → single text → emit delta + stop.
//
// `claude -p --output-format json` would emit a JSON envelope, but OCP uses `text`
// (OCP server.mjs:385: "--output-format", "text"), so we port the same.
export function anthropicChunkToIR(rawText, isFirstChunk = false) {
return {
type: 'delta',
...(isFirstChunk && { role: 'assistant' }),
content: rawText,
};
}
// Synthesize the stop chunk (called at proc.on('close', ...) with code 0).
export function anthropicStopToIR(finishReason = 'stop') {
return {
type: 'stop',
finish_reason: finishReason,
};
}
// ── CLI args builder ──────────────────────────────────────────────────────
// Ported from OCP server.mjs:384-414 (buildCliArgs), stripped of session
// management (OLP is stateless per AGENTS.md § "No conversation state").
//
// OCP server.mjs:385: const args = ["-p", "--model", cliModel, "--output-format", "text"];
// OCP server.mjs:392: args.push("--no-session-persistence"); // when no sessionInfo
// OCP server.mjs:395-400: --dangerously-skip-permissions or --allowedTools
//
// OLP variant: always --no-session-persistence (stateless). No permissions flags at D4.
function buildCliArgs(model) {
return [
'-p',
'--model', model,
'--output-format', 'text',
'--no-session-persistence',
];
}
// ── Env cleanup ───────────────────────────────────────────────────────────
// OCP server.mjs:531-534: deletes ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL,
// ANTHROPIC_AUTH_TOKEN before spawn to prevent env-level override collisions
// with the OAuth token the CLI manages internally.
// OCP server.mjs:530: const env = { ...process.env };
function buildSpawnEnv() {
const env = { ...process.env };
// OCP server.mjs:531: delete env.CLAUDECODE;
delete env.CLAUDECODE;
// OCP server.mjs:532: delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_API_KEY;
// OCP server.mjs:533: delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_BASE_URL;
// OCP server.mjs:534: delete env.ANTHROPIC_AUTH_TOKEN;
delete env.ANTHROPIC_AUTH_TOKEN;
return env;
}
// ── Spawn function (core) ─────────────────────────────────────────────────
// Returns an AsyncIterator<IRResponseChunk> per ADR 0002 § Provider contract.
// `spawnImpl` is swappable for tests (dependency injection, no real binary needed).
//
// OCP server.mjs:542: const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
// OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end();
async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
const auth = authContext ?? readAuthArtifact();
if (!auth?.accessToken) {
throw new ProviderError(
'No Anthropic OAuth token found. Run `claude auth login` or set CLAUDE_CODE_OAUTH_TOKEN.',
'AUTH_MISSING',
);
}
const bin = resolveClaudeBin();
const args = buildCliArgs(irRequest.model);
const env = buildSpawnEnv();
// Inject OAuth token so the CLI can authenticate.
// OCP server.mjs:864-888: the token is read from keychain/.credentials.json/env
// and passed as CLAUDE_CODE_OAUTH_TOKEN for the spawn invocation.
// We set it explicitly here so the plugin is self-contained.
env.CLAUDE_CODE_OAUTH_TOKEN = auth.accessToken;
const prompt = irToAnthropic(irRequest);
// OCP server.mjs:542: spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] })
const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
// OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end();
proc.stdin.write(prompt);
proc.stdin.end();
// Yield IR chunks as they arrive from stdout.
let isFirstChunk = true;
let accumulatedStderr = '';
// Convert proc events to an async generator using a push-buffer + signal approach.
const chunks = [];
let done = false;
let exitCode = null;
let exitSignal = null;
let resolveNext = null;
// timeout: tracks a pending rejectNext for spawn timeout
let rejectNext = null;
function push(item) {
chunks.push(item);
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
rejectNext = null;
r();
}
}
proc.stdout.on('data', (d) => {
const text = d.toString();
push({ type: 'data', text });
});
proc.stderr.on('data', (d) => {
accumulatedStderr += d.toString();
});
proc.on('error', (err) => {
push({ type: 'error', err });
});
proc.on('close', (code, signal) => {
exitCode = code;
exitSignal = signal;
done = true;
push({ type: 'close' });
});
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
// maxSpawnTimeMs: from hints field so tests can lower it without changing the plugin.
// Default: 600 000 ms (10 minutes) — reasonable for long Claude outputs.
const maxSpawnTimeMs = anthropic.hints?.maxSpawnTimeMs ?? 600_000;
// Overall wall-clock deadline — fires if the process hasn't closed in time.
let spawnTimedOut = false;
const spawnDeadlineTimer = setTimeout(() => {
spawnTimedOut = true;
// Kill the process and wake up the drain loop via a rejection.
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
if (rejectNext) {
const r = rejectNext;
rejectNext = null;
resolveNext = null;
r(new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
));
}
}, maxSpawnTimeMs);
// Drain the chunk buffer
try {
while (true) {
if (chunks.length === 0) {
if (done) break;
if (spawnTimedOut) {
throw new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
);
}
// Wait for the next event (or timeout rejection)
await new Promise((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
continue;
}
const item = chunks.shift();
if (item.type === 'error') {
throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED');
}
if (item.type === 'close') {
break;
}
if (item.type === 'data') {
yield anthropicChunkToIR(item.text, isFirstChunk);
isFirstChunk = false;
}
}
} finally {
clearTimeout(spawnDeadlineTimer);
}
// D24 round-2 F4 fix: if the timer fired while the drain loop was processing
// queued items, rejectNext was null at fire-time so the rejection was skipped.
// The post-loop SPAWN_FAILED + yield-stop guards both check !spawnTimedOut, so
// the generator would otherwise return normally with partial chunks despite the
// timeout. This unconditional throw closes the race — SPAWN_TIMEOUT always
// surfaces as a hard trigger to the fallback engine regardless of which path
// the timer fire took. Note: any partial chunks already yielded are discarded
// by the caller. SPAWN_TIMEOUT is intentionally excluded from D16 salvage —
// see ADR 0004 Amendment 1 § "Why SPAWN_TIMEOUT is excluded from salvage".
if (spawnTimedOut) {
throw new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
);
}
// Process close
if (exitCode !== 0 && !spawnTimedOut) {
const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`;
// Map known exit patterns to IR error chunk, then throw
throw new ProviderError(errMsg, 'SPAWN_FAILED');
}
// Normal exit: emit stop chunk
if (!spawnTimedOut) {
yield anthropicStopToIR('stop');
}
}
// ── spawn (public, contract method) ──────────────────────────────────────
// The public spawn method conforms to ADR 0002 § Provider contract:
// spawn: async (irRequest, authContext) => AsyncIterator<ResponseChunk>
//
// The `_spawnImpl` field is the injection point for tests.
// Tests set `anthropic._spawnImpl = mockSpawn` before calling `anthropic.spawn()`.
let _spawnImpl = defaultSpawn;
export async function* spawn(irRequest, authContext) {
yield* _spawnAndStream(irRequest, authContext, _spawnImpl);
}
// Test hook: allows tests to inject a mock spawn without importing child_process.
// Set anthropic._spawnImpl = mockFn before calling spawn(); reset after.
export { _spawnImpl };
export function __setSpawnImpl(impl) { _spawnImpl = impl; }
export function __resetSpawnImpl() { _spawnImpl = defaultSpawn; }
// ── estimateCost ──────────────────────────────────────────────────────────
// Best-effort token estimation using the chars/4 heuristic.
// Basis: OpenAI Cookbook "How to count tokens" rule-of-thumb: ~4 characters per token
// for English prose. This is approximate; actual tokenization differs per model.
// Reference: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken
//
// usd: null at D4 — Anthropic per-million-token rates not yet pinned.
// TODO: populate usd once rates are confirmed post-2026-06-15 billing-split audit.
export function estimateCost(request) {
if (!request?.messages) return null;
let inputChars = 0;
for (const msg of request.messages) {
const text = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
inputChars += text.length;
}
// Rough output estimate: assume response is ~50% of input length.
const outputCharsEstimate = Math.ceil(inputChars * 0.5);
return {
inputTokens: Math.ceil(inputChars / 4),
outputTokensEstimate: Math.ceil(outputCharsEstimate / 4),
currency: 'USD',
usd: null, // not pinned at D4
};
}
// ── quotaStatus ───────────────────────────────────────────────────────────
// 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 ONLY when quota_probe_enabled is false.
// All other failure paths return a structured failure shape instead of null.
//
// v0.5.1 return contract (F1+F3 — ADR 0013 Rules 3+6 + codex review):
//
// null → ONLY when quota_probe_enabled: false (opt-in off)
//
// { probe_status: 'live', probedAt, source, schemaVersion,
// stale: false, fields, raw }
// → probe succeeded recently (within QUOTA_CACHE_TTL_MS)
//
// { probe_status: 'stale', ...cachedShape, stale: true, last_fresh_at,
// failure: { kind, message, backoff_until } }
// → probe failed but cache exists (backoff active)
//
// { probe_status: 'unreachable', source, schemaVersion,
// failure: { kind, message, backoff_until? } }
// → probe failed AND no cache (includes no_credentials case when
// creds absent after opt-in, also in-backoff-no-cache)
//
// Backwards-compat note: stale:false shape gains probe_status:'live' (additive).
// stale:true shape gains probe_status:'stale' (additive). 'unreachable' replaces
// the prior null for failure cases (breaking only if caller checked null for
// "disabled" — now null strictly means disabled only).
export async function quotaStatus(_authContext) {
// ADR 0013 Rule 4 — opt-in check; null means "disabled", not "failed"
const providerCfg = _readProviderConfig('anthropic');
if (providerCfg.quota_probe_enabled !== true) {
return null;
}
const schemaVersion = _resolveSchemaVersion();
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) {
// Cache is fresh — return probe_status:'live' shape
return { ...quotaProbeState.cache.data, probe_status: 'live' };
}
// Backoff check: still in exponential backoff window — return stale cache or unreachable
if (now < quotaProbeState.backoffUntil) {
const failureInfo = {
kind: quotaProbeState.failureKind ?? 'other',
message: quotaProbeState.lastError?.message ?? 'probe failed (in backoff)',
backoff_until: quotaProbeState.backoffUntil,
};
if (quotaProbeState.cache !== null) {
// Return stale cache marked as stale (ADR 0013 Rule 3)
return {
...quotaProbeState.cache.data,
probe_status: 'stale',
stale: true,
last_fresh_at: quotaProbeState.cache.fetchedAt,
failure: failureInfo,
};
}
// In backoff but no cache — unreachable
return {
probe_status: 'unreachable',
source: 'anthropic-ratelimit-unified-headers',
schemaVersion,
failure: failureInfo,
};
}
// Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path).
// _quotaAuthReadFnForTest allows tests to inject a mock without real credential files.
const _authReadFn = _quotaAuthReadFnForTest ?? readAuthArtifact;
const creds = _authReadFn();
if (!creds?.accessToken) {
// No credentials — set failureKind so doctor/aggregator can distinguish this
quotaProbeState.lastError = { kind: 'no_credentials', message: 'no OAuth credential found', attemptedAt: now };
quotaProbeState.failureKind = 'no_credentials';
if (quotaProbeState.cache !== null) {
// Stale cache still available — return it with failure info
return {
...quotaProbeState.cache.data,
probe_status: 'stale',
stale: true,
last_fresh_at: quotaProbeState.cache.fetchedAt,
failure: { kind: 'no_credentials', message: 'no OAuth credential found' },
};
}
return {
probe_status: 'unreachable',
source: 'anthropic-ratelimit-unified-headers',
schemaVersion,
failure: { kind: 'no_credentials', message: 'no OAuth credential found' },
};
}
// 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',
// 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,
probe_status: 'live',
stale: false,
fields: probeResult.fields,
raw: probeResult.raw,
};
quotaProbeState.cache = { fetchedAt: now, data: shape };
return shape;
}
// Probe failed: _probeOnce already called _scheduleBackoff() and set lastError/failureKind.
const failureInfo = {
kind: quotaProbeState.failureKind ?? 'other',
message: quotaProbeState.lastError?.message ?? 'probe failed',
backoff_until: quotaProbeState.backoffUntil,
};
if (quotaProbeState.cache !== null) {
return {
...quotaProbeState.cache.data,
probe_status: 'stale',
stale: true,
last_fresh_at: quotaProbeState.cache.fetchedAt,
failure: failureInfo,
};
}
// No cache — return unreachable shape (F3: operator can distinguish failure modes)
return {
probe_status: 'unreachable',
source: 'anthropic-ratelimit-unified-headers',
schemaVersion,
failure: failureInfo,
};
}
// ── healthCheck ───────────────────────────────────────────────────────────
// Does NOT spawn a real claude -p request (deferred to D5 E2E audit).
// Checks: (1) binary exists, (2) auth artifact exists.
// Ported from OCP server.mjs:363-377 (checkAuth) — simplified to existence
// checks only; no live execution (per D4 spec constraint).
export async function healthCheck({ _binaryExistsFn, _authReadFn } = {}) {
const t0 = Date.now();
// 1. Binary check
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
if (!binaryExists()) {
return { ok: false, latencyMs: Date.now() - t0, error: 'claude binary not found' };
}
// 2. Auth artifact check
const authRead = _authReadFn ?? readAuthArtifact;
const auth = authRead();
if (!auth?.accessToken) {
return { ok: false, latencyMs: Date.now() - t0, error: 'auth artifact missing' };
}
return { ok: true, latencyMs: Date.now() - t0 };
}
function _defaultBinaryExists() {
const bin = resolveClaudeBin();
if (bin !== 'claude') {
// Explicit path given — check directly
return existsSync(bin);
}
// 'claude' from PATH — use which
try {
execSync('which claude', { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
return true;
} catch {
return false;
}
}
// ── 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.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.
//
// 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;
return [
{
id: 'anthropic.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`claude` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`claude` binary not found on PATH (and OLP_CLAUDE_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @anthropic-ai/claude-code',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup',
},
};
},
},
{
id: 'anthropic.oauth_token_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.accessToken) {
return { status: 'ok', message: 'OAuth credential present (.credentials.json / env / keychain)' };
}
return {
status: 'fail',
message: 'Anthropic OAuth credential missing — none of ANTHROPIC_OAUTH_TOKEN env, ~/.claude/.credentials.json, or login keychain returned a token',
evidence: {
human_steps: [
'run: claude (the first interactive launch prompts for browser OAuth login)',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
},
};
},
},
{
id: 'anthropic.quota_probe_reachable',
category: 'provider',
// v0.5.1 (F1 — ADR 0013 Rule 3 + codex review):
// Routes through quotaStatus() so it respects cache + backoff.
// Bypassing _probeOnce() directly (old code) allowed successive `olp doctor`
// invocations to each hit upstream even when backoffUntil was in the future.
//
// Status mapping based on quotaStatus() probe_status:
// null (opt-in off) → ok + advisory
// probe_status: 'live' → ok + utilization data in message
// probe_status: 'stale' → warn + age info + failure kind in message
// probe_status: 'unreachable' (no_credentials) → fail + human_steps (re-login)
// probe_status: 'unreachable' (auth_failed/rate_limited/schema_drift/network/other)
// → fail + human_steps + fix_commands
async run() {
// Inject the test auth override so tests can seed the quotaAuthReadFn.
// In production _quotaAuthReadFnForTest is null so quotaStatus() reads
// credentials normally. Tests can set it via _setQuotaAuthReadFnForTest.
if (authRead !== readAuthArtifact) {
_quotaAuthReadFnForTest = authRead;
}
try {
const result = await quotaStatus(null);
// null → opt-in off
if (result === null) {
return { status: 'ok', message: 'quota probe disabled (opt-in via config.providers.anthropic.quota_probe_enabled)' };
}
const probeStatus = result.probe_status;
if (probeStatus === 'live') {
const util5h = result.fields?.utilization_5h;
const util7d = result.fields?.utilization_7d;
const msg = `quota probe OK — 5h utilization: ${util5h !== null && util5h !== undefined ? `${Math.round(util5h * 100)}%` : 'n/a'}, 7d utilization: ${util7d !== null && util7d !== undefined ? `${Math.round(util7d * 100)}%` : 'n/a'}`;
return { status: 'ok', message: msg };
}
if (probeStatus === 'stale') {
const ageMin = result.last_fresh_at
? Math.round((Date.now() - result.last_fresh_at) / 60_000)
: null;
const failKind = result.failure?.kind ?? 'other';
const ageStr = ageMin !== null ? ` from ${ageMin}min ago` : '';
return {
status: 'warn',
message: `quota probe failed (${failKind}); returning stale cache${ageStr}; 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)',
],
},
};
}
// probe_status === 'unreachable' — distinguish failure kind
const failKind = result.failure?.kind ?? 'other';
const failMsg = result.failure?.message ?? 'probe failed';
if (failKind === 'no_credentials') {
return {
status: 'fail',
message: `quota probe enabled but no OAuth credential — ${failMsg}`,
evidence: {
human_steps: [
'run: claude (first interactive launch prompts browser OAuth login)',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
},
};
}
if (failKind === 'auth_failed') {
return {
status: 'fail',
message: `quota probe auth failure (${failMsg}); OAuth token may be expired`,
evidence: {
human_steps: [
'if OAuth token is expired: run: claude (re-login)',
'to disable probe: set providers.anthropic.quota_probe_enabled = false in ~/.olp/config.json',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
},
};
}
if (failKind === 'schema_drift') {
return {
status: 'fail',
message: `quota probe schema drift detected (${failMsg}); schema may have changed`,
evidence: {
human_steps: [
'check for OLP updates: the anthropic-ratelimit-unified-* header schema may have changed',
'file an issue if the schema has genuinely drifted (ADR 0013 Rule 5)',
],
fix_commands: [
'node -e "const {_getQuotaProbeStateForTest: s}=await import(\'./lib/providers/anthropic.mjs\');console.log(JSON.stringify(s().lastError))"',
],
},
};
}
// rate_limited / network / other
return {
status: 'fail',
message: `quota probe failed and no cached data available (${failKind}: ${failMsg})`,
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',
],
},
};
} finally {
// Restore auth override so this doctor run does not permanently affect
// the module-level state beyond this call.
if (authRead !== readAuthArtifact) {
_quotaAuthReadFnForTest = null;
}
}
},
},
];
}
// ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
// contractVersion fold-in per reviewer F3.
import modelsRegistryRaw from '../../models-registry.json' with { type: 'json' };
const _registryModels = (modelsRegistryRaw?.providers?.anthropic?.models ?? []).map(m => m.id);
const anthropic = {
name: 'anthropic',
displayName: 'Anthropic Claude',
contractVersion: '1.0',
models: _registryModels,
auth: {
type: 'oauth',
storage: 'keychain',
// Portable path via os.homedir() per hygiene requirement
path: join(homedir(), '.claude', '.credentials.json'),
refresh: 'auto',
},
spawn,
estimateCost,
quotaStatus,
healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: {
requiresTTY: false,
concurrentSpawnSafe: true,
maxConcurrent: 4,
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
// 600_000ms = 10 minutes. Tests can lower this by mutating anthropic.hints.maxSpawnTimeMs.
maxSpawnTimeMs: 600_000,
// ADR 0002 Amendment 3 (D23): explicitly cacheable (default true; named here for
// readability and as a model for future plugins that may need cacheable: false).
cacheable: true,
},
};
export default anthropic;