11 Commits
Author SHA1 Message Date
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
65681ed7d2 release(v0.5.0): Phase 5 close — quota probe + dashboard enrichment (#57)
Promotes "Unreleased" → v0.5.0 — 2026-05-26. Maintainer-triggered per
CLAUDE.md release_kit.phase_close_trigger.

Phase 5 closed with 6 D-days shipped across 7 PRs, every PR through a
fresh-context opus reviewer per Iron Rule 10, 0 blocking findings, 720
→ 756 tests, no flakies, all CI green:

- D79 governance (PR #50): ADR 0012 charter + ADR 0002 Amendment 8 +
  ADR 0013 + ALIGNMENT.md Class-specific Exceptions §1
- D79 cleanup (PR #51): reviewer N6+N7+N9 fold-in
- D80 (PR #52): anthropic plan-usage probe port (~250 LOC; OCP
  server.mjs:842-1109 → lib/providers/anthropic.mjs:quotaStatus())
- D81 (PR #53): lib/audit-query.mjs aggregateProviderQuota() +
  /v0/management/{dashboard-data,quota} quota_v2 field +
  models-registry.json quota_probe block
- D82 (PR #54): dashboard.html Claude.ai-style Plan Usage panel
  (closes v1.x roadmap #8)
- D83 (PR #55): Suite 38 (20 probe unit tests) + Suite 39 (8 dashboard
  smoke tests) + 5 test seams
- Close-prep (PR #56): README § Plan Usage + § Supported Providers
  Quota-probe column + dashboard screenshot + docs/exit-gates/phase-5-e2e.json
  + 3-finding fold-in

Changes in this commit:
- package.json: 0.4.4 → 0.5.0
- CHANGELOG.md: "Unreleased" promoted to "## v0.5.0 — 2026-05-26"
  with comprehensive release notes spanning all Phase 5 deliverables;
  fresh empty "## Unreleased" added above for Phase 6
- CLAUDE.md release_kit.phase_rolling_mode:
    current_phase: Phase 5 → Phase 6
    current_pre_release_identifier: "0.5.0-phase5" → "0.6.0-phase6"

Tag push (git tag v0.5.0 && git push --tags) happens AFTER this merges
to main. Tag push fires .github/workflows/release.yml which auto-creates
the GitHub Release with notes derived from CHANGELOG.md.

Tests: 756/756 pass locally; no production-code changes beyond version
bumps.

Authority: CLAUDE.md release_kit overlay (Iron Rule 5.5); ADR 0012
§ Exit gate (all 9 items satisfied); Phase 5 D-day commits 1605400,
187e793, 82d2e1c, 5288493, a41420d, 2b07a3b, d872330 on main.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 06:16:04 +10:00
d872330c9e docs: Phase 5 close-prep — README § Plan Usage + supported-provider matrix + live E2E artifact (#56)
* docs: Phase 5 close-prep — README § Plan Usage + supported-provider matrix + live E2E artifact

Pre-close documentation pass per ADR 0012 § Exit gate items 3 + 9.
v0.5.0 close PR (package.json bump + CHANGELOG promotion + tag) is
maintainer-triggered per CLAUDE.md release_kit.phase_close_trigger.

What this PR ships:

(1) README § What you get — added Plan-usage probe bullet with anchor to
    the new § Plan Usage section.

(2) README § Supported Providers — table gained "Quota probe (v0.5.0+)"
    column. Anthropic  live (13 anthropic-ratelimit-unified-* headers,
    opt-in via quota_probe_enabled). Codex  no public quota API.
    Mistral  per D84 spike 2026-05-26 (no /v1/usage at docs.mistral.ai/api).
    Others TBD (Phase 8+).

(3) README new § Plan Usage (live quota probe) between § Configuration and
    § API Endpoints. Documents: how the probe works (POST /v1/messages
    with max_tokens:1 + headers-only parse), opt-in config block, OAuth
    token sources (env / .credentials.json / macOS Keychain), olp doctor
    integration, schema-drift protection (Path A `strings` over compiled
    binary + Path B live probe diff), full ADR cross-refs. Embeds the
    dashboard screenshot below.

(4) README § API Endpoints — updated /dashboard / /v0/management/dashboard-data
    / /v0/management/quota rows to reflect Phase 5 changes:
    - /dashboard: Claude.ai-style Plan Usage section + 60s auto-refresh + manual button
    - /v0/management/dashboard-data: new quota_v2 field alongside legacy quota
    - /v0/management/quota: mirrors dashboard-data quota_v2 for scripted monitoring

(5) docs/img/dashboard-v0.5.0.png — dashboard screenshot rendered from
    live MacBook server JSON (utilization_5h: 36%, utilization_7d: 34%,
    representative_claim: five_hour, overage: rejected) merged into D83
    dashboard.html. Identifying IPs / hostnames / Tailscale nodes redacted
    from rendered output per public-repo hygiene rule (cc-rules AGENTS.md).

(6) docs/exit-gates/phase-5-e2e.json — sanitized record of the Live
    MacBook E2E verification (exit-gate item 9). Confirms quota_v2 shape
    produced live, anthropic probe returned 12/13 fields (overage-reset
    absent per audit memory — only fires on active overage), opt-in
    mechanism verified end-to-end (config flag flipped → server probed
    → dashboard renders). Post-test cleanup noted: temp owner key
    revoked, config restored to baseline, test server terminated.

Remaining exit-gate items for the v0.5.0 close PR (maintainer-triggered):

- CHANGELOG.md "Unreleased" → "## v0.5.0 — <date>" promotion
- package.json 0.4.4 → 0.5.0
- CLAUDE.md release_kit.phase_rolling_mode: Phase 5 → Phase 6,
  0.5.0-phase5 → 0.6.0-phase6
- Tag v0.5.0 push (triggers .github/workflows/release.yml auto-release)

Authority cited:
- ADR 0012 § Exit gate items 3 + 9
- ADR 0012 Amendment 1 (D84 NO-GO rationale in Mistral row)
- ADR 0002 Amendment 8 + ADR 0013 (the constitutional context for the
  Plan Usage section's "how it works" framing)
- D80 commit 82d2e1c (probe producer cited in API Endpoints table)
- D81 commit 5288493 (quota_v2 shape producer)
- D82 commit a41420d (dashboard UI consumer)
- D83 commit 2b07a3b (test coverage)

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

* docs: PR #56 fold-in — 3 maintainer findings (F1 doctor kind / F2 Mistral / F3 SPOT)

Maintainer review of PR #56 surfaced 3 valid accuracy issues. Folding in.

F1 [P2] — README claim "fix_oauth on 401/403, fix_provider on 429/network"
is wrong. The anthropic.quota_probe_reachable check has category: 'provider'
(anthropic.mjs:999), so deriveKind() at lib/doctor.mjs:464 maps ALL failures
to fix_provider regardless of underlying HTTP status. Furthermore, _probeOnce
collapses 401/403 → null before reaching the doctor aggregate layer
(anthropic.mjs:451), so the HTTP status isn't observable to deriveKind at
all. README would have steered AI-repair loops toward an unreachable
discriminator.

Fix: README now accurately says all probe failures discriminate to
kind: fix_provider, with the actionable text inside human_steps[] being
auth-aware (re-login recipe for OAuth failures, wait-and-retry for
rate-limit). Note that splitting the check across the provider/auth
category boundary would let fix_oauth fire for OAuth-class failures
specifically; deferred to v1.x pending consumer-reported ambiguity.

F2 [P2] — README + ADR 0012 Amendment 1 said Mistral has "no programmatic
quota API; only web console". Maintainer pointed to Mistral's Admin API
(https://docs.mistral.ai/admin/security-access/admin-api) which DOES
expose Billing and Usage queries — just gated to org-admin-scoped keys.

The D84 NO-GO conclusion is still correct: OLP's deployment posture
(maintainer's personal Le Chat Pro account, trusted-LAN per ADR 0011)
uses Vibe / Le Chat member / La Plateforme keys, NOT org-admin keys.
Provisioning + storing an org-admin token raises the credential-scope
ceiling beyond the trusted-LAN design.

Fix: tightened wording in 3 places (README Supported Providers table,
README Plan Usage provider coverage table, ADR 0012 Amendment 1) to
say "no public quota endpoint accessible to Vibe / Le Chat member /
La Plateforme API keys" + acknowledge the Admin API surface + cite it
as a forward re-entry point if OLP scope expands to org-admin context.

F3 [P3] — Supported Providers table claims it's re-generated from
models-registry.json, but the new "Quota probe (v0.5.0+)" column has
values for OpenAI/Mistral/TBD that aren't in the registry (registry only
had quota_probe.anthropic block before this PR).

Fix: closed the SPOT drift formally by adding quota_probe.openai and
quota_probe.mistral entries to the registry with {status, reason,
re_entry_point} for each, plus an admin_api_reference for Mistral. Also
added explicit "status": "live" field to quota_probe.anthropic for
symmetry. Tightened README's source-of-truth statement to name BOTH
the providers.<key> block (model metadata) and quota_probe.<key> block
(probe status/reason/source).

This makes the column fully derivable from the registry — when D84 ever
becomes GO (Mistral Admin API integration, or OpenAI publishes a quota
endpoint), the registry is the single edit point.

Test impact: 756/756 still pass. Suite 37g (registry presence + 13 fields
for anthropic) unchanged; the new openai/mistral quota_probe entries are
additive and don't break any consumer.

Authority:
- F1: lib/doctor.mjs:464 deriveKind logic + anthropic.mjs:999 category
- F2: https://docs.mistral.ai/admin/security-access/admin-api + ADR 0011
  trusted-LAN deployment context for the "out of scope" framing
- F3: CLAUDE.md release_kit overlay § "Supported Providers table sourced
  from models-registry.json" — same SPOT discipline applies to the new
  D81 column

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:59:24 +10:00
2b07a3bd1b test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) (#55)
* test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5)

ADR 0012 D83 row: comprehensive test coverage for the Phase 5 quota probe
machinery (D80) and dashboard rendering (D82).

## Authorities cited

- ADR 0012 D83 (D-day specification)
- ADR 0013 Rules 2–6 (quota probe constraints being tested)
- D80 PR #52 (anthropic.quotaStatus() + _probeOnce + _parseRateLimitHeaders)
- D81 PR #53 (aggregateProviderQuota quota_v2 shape)
- D82 PR #54 (dashboard.html Claude.ai-style restructure)
- Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md

## Test seams added to lib/providers/anthropic.mjs

Minimal test seams added — 0 production-logic changes:
- `_setQuotaUrlsForTest(apiUrl, oauthUrl)` — redirects probe HTTP to local
  mock server (auto-detects http: vs https: to switch transport module)
- `_resetQuotaProbeStateForTest()` — resets cache + backoff + URLs + auth seam
- `_resetQuotaStateOnlyForTest()` — resets cache + backoff only (URLs stay)
- `_getQuotaProbeStateForTest()` — returns direct reference to quotaProbeState
  for test assertions and controlled state mutation
- `_setQuotaAuthReadFnForTest(fn)` — injects a mock auth reader into quotaStatus()
  so tests are not affected by real ~/.claude/.credentials.json on the machine
  (fixes 38f: no-auth test was finding real keychain credentials)

## Suite 38 — 20 quota-probe unit tests (38a–38t)

38a: all 13 ratelimit-unified-* headers parsed correctly (numeric types, null defaults)
38b: missing overage-reset → overage_reset: null
38c: missing all three overage fields → all three null
38d: new 5h-status + 7d-status fields (NEW vs OCP 2026-04) parsed correctly
38e: quota_probe_enabled: false → null without HTTP call
38f: auth returns null → quotaStatus returns null without HTTP call
38g: 200 + all 13 headers → full shape with stale:false + backoff reset
38h: cache hit within 5min TTL → no second HTTP call (request count stays 1)
38i: expired cache (6min > 5min TTL) → fires fresh HTTP probe
38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8 §3)
38k: 429 + stale cache → returns stale cache with stale:true + last_fresh_at
38l: 429 + no cache → null + backoff scheduled (backoffUntil in the future)
38m: exponential backoff growth: 60s→120s→240s→cap at 3600s
38n: successful probe resets backoffMs to 60s + backoffUntil to 0
38o: schemaVersion from models-registry.json (falls back to constant)
38p: doctor quota_probe_reachable disabled → ok with opt-in advisory
38q: doctor probe enabled + probe succeeds → ok with utilization in message
38r: doctor probe enabled + fails + stale cache → warn
38s: doctor probe enabled + fails + no cache → fail with fix_commands
38t: doctor probe enabled + no creds → fail with human_steps only

## Suite 39 — 8 dashboard rendering smoke tests (39a–39h)

39a: /dashboard with owner token → 200 + text/html
39b: /dashboard without token → 401
39c: /dashboard with guest key → 401 (owner-only_block enforcement per ADR 0008 §8)
39d: HTML contains "Plan Usage" header (D82 panel)
39e: HTML contains ↻ Refresh button (D82 manual refresh)
39f: HTML contains QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh)
39g: HTML contains visibilitychange / visibilityState guard (D82 ADR 0012)
39h: HTML contains quota_v2 consumer + legacy renderQuota fallback code

## Test count: 727 → 755 (+28)

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

* test: D83 fold-in — fix 38j misleading name + add 38j2 positive-path + fix 38o ESM require

Fresh-context reviewer (PR #55) flagged one blocking issue + 1 nit:

BLOCKING (Q5) — 38j misleading name + uncovered 401→refresh→retry positive path.
The original 38j test description said "401 → refresh succeeds → retry probe
succeeds" but the test actually asserted the OPPOSITE behavior (401 with no
refreshToken → null, no retry). The most important non-trivial control-flow
branch in _probeOnce (anthropic.mjs:451-457 the refresh-and-retry on 401) was
uncovered, and the misleading name actively masked the gap.

Fix:
- Renamed 38j to accurately describe what it tests: "401 with no refreshToken
  → null (idempotent-failure, no refresh attempt)". The test pins valuable
  behavior — idempotent-failure when refreshToken is absent — but now its
  name matches.
- Added 38j2 (NEW) to exercise the actual positive-path: inject creds with
  refreshToken via _setQuotaAuthReadFnForTest, mock returns 401 on first API
  call + 200 with 13 headers on retry. Asserts: 2 API calls, 1 OAuth call,
  retry parsed full shape, OAuth body contains the injected refreshToken
  (Rule 1 credential reuse verification).

NIT (Q5 dead assertion) — 38o tried to read models-registry.json via
require('fs') which is undefined in ESM. The assert was silently never
exercised. Replaced with the already-imported readFileSync from node:fs
(added _readFileSync38 import). Also tightened: schemaVersion MUST equal
the registry's quota_probe.schema_version (no longer conditional on
"if expected" which was always falsy).

Other reviewer nits deferred (non-blocking per reviewer):
- N2: seam naming convention (_ vs __) — defer
- N3: commit message "zero production changes" — note for v0.5.0 close
- N4: _getQuotaProbeStateForTest returns mutable ref — defer
- N5: test seam runtime guard — defer
- N6: coverage gaps (concurrent probes, registry-missing fallback,
       network error path) — v1.x roadmap follow-ups
- N7: Suite 39 stray error listener — defer (benign)

Test count: 755 → 756 (+1 net, +2 new minus existing 38j rename).
All 756 pass; 0 fail. Local re-run: stable.

Authority: PR #55 review thread (D83 fresh-context opus reviewer)
+ ADR 0013 Rule 1 (credential reuse via refresh).

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-26 17:47:15 +10:00
a41420d0fc feat: D82 — dashboard UI Claude.ai-style (Phase 5) (#54)
* feat: D82 — dashboard UI Claude.ai-style per-provider rows (Phase 5)

Implements ADR 0012 D82: restructures dashboard.html to render
quota_v2 data (produced by D81 / PR #53) in a Claude.ai-style
per-provider row layout. Closes v1.x roadmap #8.

## What changed

### dashboard.html (A–G)

A. New "Plan Usage" section at top (full-width, above the 2-col grid):
   - Per-provider rows rendered from `data.quota_v2`
   - Each row: provider badge (colored chip), status dot + chip
     (live/stale/unavailable), schema version tag
   - Two utilization bars (5h + 7d) with:
     - Rounded gradient bar (green <50% / amber 50-80% / red >80%)
     - Label "Current 5-hour session: 49%" / "Weekly all-models: 31%"
     - Right-side reset countdown (see B)
   - Bottom chips: representative-claim badge (purple), overage chip
     (amber/green), fallback-percentage chip, last-fresh "Updated N
     min ago" tag; stale rows show amber ⚠ stale data chip with tooltip
   - Unavailable rows: provider badge + reason text only; no bars

B. formatResetCountdown(epochSeconds):
   - < 1 hour:   "Resets in 23 min"
   - 1–24 hours: "Resets in 12hr 30min"
   - < 7 days:   "Resets Sun 9:00 PM"  (weekday + 12h time)
   - >= 7 days:  "Resets May 31 9:00 PM" (month day + time)
   - past:       "Resetting now…"
   Uses toLocaleString('en-US', { hour12: true }).

C. 60s auto-refresh (quota_v2 only) with visibilityState guard:
   - Separate timer (quotaRefreshTimer); does NOT replace the 30s poll
   - Pauses on 'hidden'; resumes + immediate re-fetch on 'visible'
   - Other 3 panels (24h, 30d, top fallback) keep 30s cadence unchanged

D. Manual refresh button (↻ Refresh) in Plan Usage header:
   - 2-second spam guard (button disables post-click)
   - Spinning ⟳ icon during fetch
   - Re-enables after fetch completes (success or error)

E. Graceful quota_v2 / legacy quota fallback:
   - If data.quota_v2 is present and non-empty → render Plan Usage rows;
     hide legacy "Quota (per provider)" panel
   - If data.quota_v2 is absent/empty → show note in Plan Usage area;
     surface legacy data.quota in the original table panel
   - Guards operator running an older OLP build (pre-D81)

F. Visual polish: rounded bars, gradient fills, airy whitespace, mobile-
   responsive (bars reflow on narrow viewports via flex-wrap). Color
   palette: #10b981 (green), #f59e0b (amber), #ef4444 (red) matching
   Tailwind emerald/amber/red-500 per spec.

G. Other 3 panels (24h, 30d, top fallback) and their 30s poll cadence
   are IDENTICAL to D51. Only the Quota panel restructures.

### docs/v1x-roadmap.md (H)

Marks entry #8 as " CLOSED (D82, v0.5.0)". Adds closure status,
PR ref, and a brief note inside the entry body. Updates reading-order
header paragraph to include #8 in the closed list.

## Authority + citations

- ADR 0012 D82 — Claude.ai-style restructure D-day spec
  (docs/adr/0012-phase-5-charter-quota-probes-dashboard.md § D-day table)
- D81 PR #53 — quota_v2 shape producer (commit 5288493);
  ProviderQuotaEntry shape per ADR 0008 Amendment 1 § 3
- Maintainer reference 2026-05-26 — claude.ai/settings/usage screenshot;
  "Resets in 1hr 6min" / "Resets Sun 9:00 PM" string format
- v1.x roadmap #8 — closed by this commit
  (docs/v1x-roadmap.md #8 — Dashboard enrichment)

## Test impact

npm test: 727 pass / 0 fail (unchanged). dashboard.html is frontend-only;
D83 ships Suite 38/39 (probe unit tests + dashboard smoke tests).

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

* fix(dashboard): D82 reviewer Nit #4 — gate overage chip on real status

When entry.overage = { status: null, disabled_reason: null } (audit-query's
default shape when the provider doesn't supply overage info), the chip rendered
as amber "Overage: —" which falsely suggests a warning state.

Now: chip only renders when entry.overage.status is truthy (i.e., Anthropic
actually returned an overage-status header). Truly-missing overage info shows
no chip at all, matching the maintainer's intent.

Identified by D82 fresh-context reviewer (PR #54 thread) as the only
maintainer-visible nit worth folding in pre-merge. 1-line change. All 727
tests continue to pass.

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-26 17:20:49 +10:00
5288493f19 feat: D81 — dashboard-data quota_v2 shape + models-registry schema_version (Phase 5) (#53)
Authority citations (required per CLAUDE.md § Hard requirements):
  1. ADR 0012 D81 — the D-day being implemented (Phase 5 charter, audit-query
     + dashboard-data extension row in § D-day table)
  2. ADR 0013 Rule 5 — schema_version in models-registry.json mandate. D80
     used a local constant QUOTA_SCHEMA_VERSION = '2026-05-26' (reviewer nit
     #4 at PR #52). D81 folds in Rule 5 compliance: adds quota_probe.schema_version
     to models-registry.json and has anthropic.mjs read from there with the
     constant as fallback via _resolveSchemaVersion().
  3. ADR 0008 — the audit-query design being amended (Amendment 1 added at D81
     to docs/adr/0008-dashboard-and-audit-query.md documenting: quota_probe in
     registry, aggregateProviderQuota() API shape, quota_v2 key, deprecation
     timeline for legacy quota key).
  4. D80 PR #52 (commit 82d2e1c) — producer of the quotaStatus() shape that
     D81 normalizes. The 13-field anthropic-ratelimit-unified-* shape from
     _parseRateLimitHeaders() is consumed by _normalizeAnthropicQuota() here.

Changes (A–G per D81 spec):

A. models-registry.json — new top-level quota_probe key
   - quota_probe.schema_version = '2026-05-26'
   - quota_probe.anthropic.{ source, endpoint, fields_pinned[13] }
   - fields_pinned is load-bearing for drift detection (ADR 0013 Rule 5)

B. lib/providers/anthropic.mjs — _resolveSchemaVersion() helper
   - Reads quota_probe.schema_version from modelsRegistryRaw at call time
   - Falls back to QUOTA_SCHEMA_VERSION constant if registry field absent
   - quotaStatus() now uses _resolveSchemaVersion() instead of raw constant

C. lib/audit-query.mjs — aggregateProviderQuota() export
   - Normalizes per-provider quotaStatus() returns to ProviderQuotaEntry shape
   - Live → { status:'live', utilization, reset, representative_claim, … }
   - Stale → { status:'stale', … }
   - null return → { status:'unavailable', reason:'no public quota api or probe disabled' }
   - throw → { status:'unavailable', reason:<error.message> }
   - getQuotaStatus injection point for full test isolation (D81 §F)

D. server.mjs handleManagementDashboardData — quota_v2 added
   - Calls auditAggregateProviderQuota({ providers: loadedProviders })
   - Both legacy quota (backwards compat) and quota_v2 in response
   - Graceful degradation: quota_v2 failure → warn log + empty array, rest of
     payload unaffected

E. server.mjs handleManagementQuota — quota_v2 added (mirrors D)

F. test-features.mjs — Suite 37 (7 new tests)
   - 37a: live shape normalization (status=live, utilization, reset, etc.)
   - 37b: stale shape (status=stale)
   - 37c: null returns → unavailable entries
   - 37d: throw path → unavailable with reason
   - 37e: mixed providers (live + unavailable)
   - 37f: getQuotaStatus injection verified
   - 37g: models-registry.json has quota_probe.schema_version + 13 fields_pinned

G. docs/adr/0008-dashboard-and-audit-query.md — Amendment 1 added

Test delta: 720 → 727 (+7), 0 failures.

Live quota_v2 JSON sample (illustrative — probe is opt-in per ADR 0013 Rule 4;
real values require quota_probe_enabled:true + valid OAuth credentials):

  GET /v0/management/dashboard-data (owner-only)
  {
    "quota_v2": [
      {
        "provider": "anthropic",
        "status": "live",
        "schema_version": "2026-05-26",
        "last_fresh_at": 1748300000000,
        "utilization": { "5h": 0.49, "7d": 0.31 },
        "reset": { "5h": 1748290000, "7d": 1748500000, "overall": 1748300000, "overage": null },
        "representative_claim": "five_hour",
        "fallback_percentage": 0.5,
        "overage": { "status": "rejected", "disabled_reason": "org_level_disabled_until" },
        "raw_available": true
      },
      {
        "provider": "openai",
        "status": "unavailable",
        "reason": "no public quota api or probe disabled",
        "schema_version": null,
        "last_fresh_at": null,
        "utilization": null,
        "reset": null,
        "representative_claim": null,
        "fallback_percentage": null,
        "overage": null,
        "raw_available": false
      }
    ]
  }

When probe is disabled (default), anthropic entry also returns unavailable:
  { "provider": "anthropic", "status": "unavailable",
    "reason": "no public quota api or probe disabled", ... }

NOT modified: dashboard.html (D82), codex.mjs, mistral.mjs, any quotaStatus()
return shape from anthropic.mjs (D80 contract unchanged — normalization is in
the new audit-query layer only).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:06:41 +10:00
82d2e1cbea 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>
2026-05-26 16:51:01 +10:00
187e79321f docs: D79 cleanup — ALIGNMENT.md N9 + ADR 0012 Amendment 1 (D84 NO-GO) (#51)
Two governance-layer cleanup items bundled per Iron Rule 11 (same layer +
same severity). Both are docs-only, both close-loop on D79 reviewer + spike.

(1) ALIGNMENT.md N9 cross-reference — the third outside-PR nit from the D79
fresh-context reviewer (PR #50). Class-specific Exceptions section gains its
first numbered exception (Anthropic plan-usage probe via direct /v1/messages).
Previously the section said "(none at project founding)" + invited "future
Rule 3 deviation"; this entry is a Rule 2 deviation, so the section header
text was updated to "Any Rule 2 or Rule 3 deviation".

(2) ADR 0012 Amendment 1 — D84 Mistral NO-GO per 2026-05-26 spike. Per the
D79 reviewer N5 fold-in, the Mistral GO/NO-GO decision was scheduled for
D79 close (before D80 starts). Spike completed 2026-05-26 with verdict NO-GO:

- docs.mistral.ai/api has no usage/quota/credits endpoint
- Direct probe /v1/usage returns 404
- Mistral's "Limits and Usage" help points only at web console UI
- No x-ratelimit-* response headers documented on /v1/chat/completions
- OLP mistral.mjs DL-7 comment already records this from independent
  D8 investigation

Disposition: D84 row struck through in D-day plan. Mistral dashboard row in
D82 will show "spend tracking only" badge from audit-query aggregates. DL-7
remains as the documented re-entry point. Phase 5 total D-day budget revised
~6 → ~5 (anthropic-only quota probe).

Outside-PR nits N6 + N7 already addressed in ~/.cc-rules commit 9fa533a
(audit memory chronology + D-day mapping fixes).

Authority:
- N9: PR #50 review thread (D79 fresh-context opus reviewer)
- D84 NO-GO: docs.mistral.ai/api spike 2026-05-26; OLP DL-7 precedent

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:35:59 +10:00
1605400052 docs: D79 — Phase 5 constitutional layer (ADR 0012 + ADR 0002 Amendment 8 + ADR 0013) (#50)
* docs: D79 — Phase 5 constitutional layer (ADR 0012 + ADR 0002 Amendment 8 + ADR 0013)

Three coupled governance documents land together as the Phase 5 constitutional
layer (Iron Rule 11 IDR — reviewing them separately cannot verify
consumer-producer alignment). Phase 5 opens 2026-05-26; D79 is governance-only,
no code changes.

- ADR 0012 (Phase 5 charter) — port OCP's plan-usage probe to
  `lib/providers/anthropic.mjs:quotaStatus()` (D80) + extend
  `/v0/management/dashboard-data` for new shape (D81) + Claude.ai-style
  dashboard restructure with 1-min auto-refresh + manual refresh (D82) +
  Suite 38/39 tests (D83) + optional mistral probe at D84 (codex skipped —
  no public API) + v0.5.0 close (maintainer-triggered). ~6 D-days.

- ADR 0002 Amendment 8 (direct-API READ-ONLY exemption) — plugin contract
  amendment permitting quotaStatus() to call provider HTTP APIs directly,
  subject to three constraints: READ-ONLY (no mutating calls),
  subscription-scope (reuses spawn-path credentials), idempotent failure
  (returns null on any error, never throws). No other contract method gains
  this permission.

- ADR 0013 (OAuth READ-ONLY consumption + schema-drift mitigation) —
  implementation discipline for ADR 0002 Amendment 8. Seven rules: (1)
  credential reuse via plugin's readAuthArtifact(), (2) READ-ONLY at wire
  (max_tokens:1, headers-only parse, body discarded), (3) cache TTL 5min +
  60s-3600s exponential refresh backoff + stale-cache-on-failure, (4)
  opt-in via `~/.olp/config.json providers.<name>.quota_probe_enabled`
  (default false), (5) schema-drift mitigation via dual-path verification
  (compiled-binary `strings` + live API probe diff), (6) failure
  transparency through `olp doctor` + dashboard staleness markers, (7)
  explicit out-of-scope clarifications.

Pre-flight institutional-knowledge audit (Iron Rule 12 prior-art search)
captured at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`
(cross-machine git-sync). Findings:

- OCP probe (server.mjs:842-1109) still works against current
  api.anthropic.com — tested live from PI231 OAuth credentials 2026-05-26.
- 13 `anthropic-ratelimit-unified-*` response headers confirmed
  (3 new since OCP 2026-04 capture: 5h-status, 7d-status, overage-reset;
  no removals or renames).
- Claude Code v2.1.x is now distributed as compiled native binary
  (Mach-O on macOS, ELF on Linux) — OCP's "grep cli.js" verification is no
  longer applicable. ADR 0013 Rule 5 replaces with dual-path verification
  (`strings` over the binary + live API probe diff).
- OAuth refresh path (platform.claude.com/v1/oauth/token + client_id
  9d1c250a-...) all unchanged.

Authority:
- ALIGNMENT.md Rule 1 (citation): audit memory + OCP server.mjs:842-1109 +
  live `/v1/messages` probe transcript 2026-05-26.
- ALIGNMENT.md Rule 2 (provider-CLI-as-authority): Amendment 8 documents the
  exemption; the probe mirrors observed CLI behaviour.
- ALIGNMENT.md Rule 5 (CI alignment.yml): not triggered (docs/ excluded by
  workflow `paths:` filter); blacklisted `/api/oauth/usage` token referenced
  only as meta-references ("must continue to blacklist").
- CLAUDE.md release_kit overlay: Phase 5 open; D-day commits stay under
  "Unreleased" until maintainer-triggered v0.5.0 close.

Iron Rule 10: fresh-context reviewer required before merge per CLAUDE.md
hard requirement #3.

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

* docs: D79 fold-in — 6 in-PR nits from fresh-context reviewer (PR #50)

Reviewer verdict: APPROVE_WITH_MINOR (0 blocking, 9 nits — 6 in-PR, 3 outside-PR).
Folding in the 6 in-PR nits here; the 3 outside-PR ones (audit-memory chronology,
audit-memory D80/D81 mapping, ALIGNMENT.md cross-ref to Amendment 8) are deferred.

Folded-in nits:

1. ADR 0002 Amendment 8: added a 5th "does NOT permit" bullet making per-endpoint
   containment explicit. Amendment 8 permits the kind of call; ADR 0013 Rule 2
   enumerates which specific endpoint. Re-opening per-endpoint scope requires an
   ADR 0013 amendment, not a Amendment-8-only interpretation.

2. ADR 0013 Rule 5: added "Path A prerequisites" paragraph documenting that
   strings (GNU/BSD binutils/coreutils) + Claude Code v2.1.x install are required
   for compiled-binary verification. Windows reviewers need WSL or binutils-mingw.

3. ADR 0013 Rule 5: added "Trigger for re-running the diff" paragraph naming three
   explicit hooks for major-version-bump detection: Annual Alignment Audit
   (14 May), olp doctor anthropic.quota_probe_reachable failure, manual
   maintainer attention. Documented graceful-degradation failure mode.

4. ADR 0012 D80 estimate: 1.5d → 2d. Reviewer flagged 1.5d as optimistic
   compared to D61-D63 (2.5d for narrower SSE heartbeat scope). Aligning.

5. ADR 0012 D84: moved Mistral GO/NO-GO spike to D79 close (before D80 starts),
   not mid-phase. Reduces mid-phase scope drift risk. Outcome will be amended
   into this charter as a D79-close amendment.

6. ADR 0012 Authority + cross-references: replaced "Claude Code <version> §
   OAuth bearer + ratelimit headers" with "compiled-binary strings evidence
   per audit memory § Path A". Claude Code v2.1.x has no traditional section
   structure because it is a Mach-O / ELF compiled binary.

Deferred (outside-PR) nits documented in PR review thread:
- Audit memory historical-table chronology error (cb6c2a8 placed last; was
  second chronologically — narrative arc still holds, dates need correction).
- Audit memory D80/D81 mapping mismatch (memory says D81 adds new fields;
  ADR 0012 says D80 parses all 13).
- ALIGNMENT.md cross-reference to Amendment 8 (Class-specific Exceptions
  subsection should name Amendment 8 explicitly).

All three outside-PR items are docs-only and not load-bearing for D80
implementation. Will fold in either at D80 commit (audit-memory updates)
or as a tiny constitutional cleanup PR (ALIGNMENT.md cross-ref).

Iron Rule 10: reviewer was a fresh-context opus subagent; their full review
is recorded in PR #50 thread.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:30:09 +10:00
704d4fc8a0 fix+docs+release(v0.4.4): D78 — olp-connect stale strings + CDN-safe README URL + pre-publish audit (#49)
* fix+docs+release(v0.4.4): D78 — olp-connect stale strings + README CDN-safe tag URL + repo public-flip pre-audit

Patch release on top of v0.4.3. Three small issues caught when running
olp-connect for real on MacBook (D77 client-install verification):

## G11: repo visibility flip

Repo dtzp555-max/olp flipped PRIVATE -> PUBLIC during this session.
Closes the original G11 finding (anonymous curl can't fetch raw URL from
private repos). Pre-publish audit (per cc-rules pre-publish-audit.md
checklist):
- Identity scrub: 0 hits — no taodeng, no 老大, no /Users/.../ paths,
  no personal hostnames, no personal emails, no real LAN IPs (only RFC
  documentation placeholders 192.168.1.10 + 10.0.0.5)
- Credential scrub: gitleaks 'no leaks found' — all olp_ matches are
  placeholder (olp_XXXX...) or test fixtures (olp_not-a-real-key-...)
- Git history: maintainer accepted Option A (GitHub-account email already
  verified-public on profile; flip exposes nothing new)

## G11 mitigation: README curl URL CDN-cache-safe

GitHub raw CDN serves a stale 404 for /main/<file> for ~5-15min after a
private->public visibility flip (negative-cache TTL). Tag-pinned URLs
bypass this because the tag ref was never queried while private.

D78 makes README's primary olp-connect curl URL tag-pinned:
  bash <(curl -fsSL .../v0.4.4/bin/olp-connect) <ip>
with /main/ listed as alternative for trusted-head users.

## G12: detect_openclaw claimed plugin not shipped

bin/olp-connect's OpenClaw detection block said "The OpenClaw OLP plugin
(D71-D73) is NOT YET SHIPPED" — but D71-D73 shipped olp-plugin/ at
v0.4.0. Replaces stale text with real install instructions:

  git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo
  openclaw plugins install /tmp/olp-repo/olp-plugin
  # or symlink: ln -sf .../olp-plugin ~/.openclaw/extensions/olp

Points at docs/integrations/openclaw.md for the full setup with
dedicated bot apiKey + restart-gateway notes.

## G13: olp-connect self-version hardcoded literal

Pre-D78 the script declared OLP_CONNECT_VERSION="0.4.0-phase4" as a
hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3.
D78 derives the version at runtime from sibling package.json via
python3. When invoked from a checked-out repo, version resolves to the
actual value; when curl-piped (no on-disk package.json next to script),
falls back to "unknown".

  bash bin/olp-connect --version  # -> olp-connect 0.4.4 (automatic)

## Test count

717 (v0.4.3) -> 720 (v0.4.4). +3 D78 regression tests in Suite 36:
- 36v: pins absence of NOT YET SHIPPED text + presence of real install path
- 36w: pins runtime version derivation from package.json
- 36x: pins README tag-pinned URL recommendation

## Authority

- D77 MacBook client-install verification session (2026-05-26)
- ~/.cc-rules/docs/guides/pre-publish-audit.md (the checklist that
  preceded the visibility flip)
- Process learning: every README that includes a `curl raw-URL | bash`
  install pattern should pin to a release tag (not /main/) for CDN-
  cache resilience.

## Out of D78 scope (deferred)

- F6 (doctor client-side limitation) — Phase 5 ADR amendment
- D75 reviewer P2-1 (ADR 0004 per-hop schema) + P2-2 (defensive type
  assert) — non-blocking
- scripts/migrate-from-ocp.mjs — Phase 7

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

* fix: D78 reviewer P2 fold-in — _resolve_version defensive guards (require /bin suffix + env-var path passthrough + nounset default)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:21:20 +10:00
6605b7b14a feat+docs+release(v0.4.3): D76 — README install-path overhaul + OLP_BIND env + AI-install prompt + ADR 0011 amendment (#48)
10 README install gaps catalogued and fixed in one D-day after v0.4.2's
PI231 E2E session exposed that the v0.4.0-v0.4.2 README Quick Start was
fictional (npm package isn't published; olp setup/start commands don't
exist). F5 (OLP_BIND env) ships in the same patch so the documented LAN
onboarding flow actually works. AI-driven install prompt added per Phase 4
charter brainstorm Top-5 inheritance candidate #2 (D64-D67 built the
doctor framework; D76 closes the README half).

## G1-G7: README Quick Start now real

Rewrote § "Manual install" from placeholder text to the empirically-verified
sequence:
- Prerequisites (Node >= 18 + provider CLI install matrix)
- git clone + npm test verify
- olp-keys keygen --owner FIRST (allow_anonymous=false default needs a key)
- Per-provider OAuth (claude setup-token / codex login --device-auth /
  MISTRAL_API_KEY)
- ~/.olp/config.json with the minimum that actually serves traffic
- npm start
- Smoke-test via curl + olp doctor
- IDE pointing

## G8 / F5: OLP_BIND env shipped

server.mjs:
- const BIND = process.env.OLP_BIND ?? '127.0.0.1' (safe default unchanged)
- server.listen(PORT, BIND, ...) replaces hard-coded '127.0.0.1'
- New startup warn anonymous_key_advertised_with_lan_bind fires when
  OLP_BIND is non-loopback AND auth.advertise_anonymous_key: true
  (operator visibility into trust-context overlap)

Pre-D76 the server only accepted loopback connections, so the documented
olp-connect <ip> family-onboarding flow was unreachable from LAN without
SSH tunneling. F5 makes ADR 0011 operational instead of aspirational.

## G10: AI-driven install prompt

README § "Install with your AI (the fast path)" — verbatim prompt the
operator pastes into Claude Code / Cursor / Copilot / Aider. The AI
follows README + uses olp doctor --json next_action.ai_executable[] for
self-repair, stopping only when human_required[] is non-empty (provider
OAuth dances). Closes the Phase 4 brainstorm #2 inheritance candidate.

## Opening compressed

§ "Why OLP" (3 paragraphs of Anthropic 2026-06-15 billing history)
removed from the top. The OCP-trigger context moved to § "Migration
from OCP" at the bottom, condensed into a single paragraph. New users
land on value-prop + § "What you get" + install paths without needing
to digest 2026-05-14 billing history first. OCP users get a one-line
pointer at the top.

## Configuration + Env Variables sections updated

- § Configuration: placeholder replaced with full ~/.olp/config.json
  schema documentation, every field cross-referenced to its ADR
- § Environment Variables: added OLP_BIND, OLP_API_KEY, OLP_OWNER_TOKEN,
  OLP_PROXY_URL rows that were used in the manual-install flow but
  previously undocumented

## ADR 0011 § Deployment configurations amendment

Codifies the three deployment trust contexts:
- 127.0.0.1 (loopback only) — safe with any auth posture
- RFC1918 / tailnet / specific LAN IP — anonymous_key OK (the documented
  trusted-LAN zero-config family onboarding flow)
- Public IP — incompatible with advertise_anonymous_key: true

Documents the new anonymous_key_advertised_with_lan_bind startup warn
event. Closes ADR 0011's pre-D76 dangling reference to a non-existent
BIND_ADDRESS concept.

## Test count

714 (v0.4.2) -> 717 (v0.4.3). +3 D76 regression tests in Suite 36
(36s/36t/36u) pinning OLP_BIND wiring + safety warn + ADR amendment.

## Process learning

Every D-day reviewer rubric should add "open README §-Quick-Start and
verify the commands literally exist + work in the current repo" — would
have caught G1-G7 at v0.4.0 close. Combined with D74 (review-against-spec)
+ D75 (review-without-deployment), D76 (review-without-following-README)
codifies the third tier of review discipline.

## Out of D76 scope (deferred)

- F6 (doctor client-side vs server-side check separation) — needs design
  ADR for --remote mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive
  typeof hopModel === 'string') — both genuine follow-ups, neither
  blocking.
- scripts/migrate-from-ocp.mjs — Phase 7.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:32:02 +10:00
22 changed files with 4224 additions and 116 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens. - `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens.
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5). - `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped; files marked ✅ are Phase 2 deliverables. The shipped set as of D47 is: `server.mjs` (with Phase 2 auth middleware + audit wire + owner-vs-non-owner gating), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `bin/olp-keys.mjs` (D47), `models-registry.json`, `test-features.mjs` (Suites 1922). Phase 2 functional scope is complete; remaining is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md `release_kit.phase_close_trigger`). **Implementation status note (as of 2026-05-27):** Phase 5 (Quota Probes + Dashboard Enrichment) is closed at v0.5.0 + v0.5.1 hotfix. The shipped set includes all Phases 15 deliverables. v0.5.1 hotfix (2026-05-27) fixes three codex review findings: F1 (doctor check bypassed backoff by calling `_probeOnce` directly — now routes through `quotaStatus()`), F2 (200 with empty `anthropic-ratelimit-*` headers was cached as live — minimum-viable-schema gate added), F3 (null collapsed all failure modes — `probe_status:'unreachable'` shape + `failure`/`failure_kind`/`backoff_until` fields added). See ADR 0008 Amendment 2 + ADR 0013 Rule 3/5 clarifications. Phase 6 is next (per CLAUDE.md `release_kit.current_phase`).
--- ---
+12 -2
View File
@@ -196,9 +196,19 @@ In addition to the recurring 14 May audit below, the following one-shot audits a
## Class-specific Exceptions ## Class-specific Exceptions
(none at project founding) Any Rule 2 or Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale.
Any future Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale. ### 1. Anthropic plan-usage probe via direct `/v1/messages` call (Phase 5, D79 — 2026-05-26)
**Class:** Rule 2(a) — provider-plugin scope. The Anthropic plugin's `quotaStatus()` calls `POST https://api.anthropic.com/v1/messages` directly rather than spawning `claude -p`. Under the strict reading of Rule 2(a), plugins must mirror provider-CLI behaviour; under the strict reading, this is a deviation because the spawn path goes through the CLI binary and the probe path does not.
**Authority:** ADR 0002 Amendment 8 (governance) + ADR 0013 (implementation discipline) + ADR 0012 (Phase 5 charter). Schema pin: `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` (compiled-binary `strings` + live API probe evidence). PR #50.
**Rationale:** Claude Code's compiled binary makes the same `POST /v1/messages` call internally (verified by `strings` over the v2.1.142 / v2.1.150 Mach-O / ELF binary). The probe mirrors that observed CLI behaviour without introducing a new wire format or output assumption. The exemption is bounded by ADR 0002 Amendment 8's three constraints (READ-ONLY, subscription-scope, idempotent-failure) + ADR 0013's seven implementation rules (notably Rule 2's per-endpoint enumeration — only `POST /v1/messages` is permitted).
**Reviewer:** fresh-context opus subagent on PR #50 (Iron Rule 10 + CLAUDE.md hard requirement #3). Verdict: APPROVE_WITH_MINOR. Six in-PR nits folded in; three outside-PR nits documented and addressed (this entry is one of them — N9).
**Re-evaluation trigger:** if Anthropic publishes a public documented quota endpoint (e.g. `GET /v1/usage`), this exception is RETIRED and the plugin migrates to the documented endpoint, deleting this exception by amendment PR. Until that hypothetical retirement, this exception is the canonical entry.
### Controlled deviations (entry-surface scope) ### Controlled deviations (entry-surface scope)
+134 -1
View File
@@ -4,7 +4,140 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased ## Unreleased
(empty — Phase 5 entries land here once Phase 5 opens) (empty — Phase 6 entries land here once Phase 6 opens)
## v0.5.1 — 2026-05-27
**Hotfix — Quota probe cache/backoff/schema-drift correctness (codex review findings F1F3).** Three production-quality bugs in the v0.5.0 quota probe, reproduced by codex with local mocks, are corrected. 756 → 759 tests (3 new regression tests); 4 existing test assertions updated to reflect the v0.5.1 return-shape contract.
### Fixes
- **F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3).** `anthropic.quota_probe_reachable` doctor check called `_probeOnce(auth)` directly, bypassing the module-level `quotaProbeState.backoffUntil` check. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (60s-3600s exponential backoff is mandatory for all consumers). **Fix:** doctor check now routes through `quotaStatus()`, which enforces cache + backoff. ADR 0013 Rule 3 clarification added: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly."
- **F2 [P2] — 200 with empty `anthropic-ratelimit-*` headers cached as live data (ADR 0013 Rule 5).** `_probeOnce` treated any 200 OK (regardless of header content) as a successful probe, caching it with `stale: false` even when zero `anthropic-ratelimit-*` headers were present. A proxy stripping headers, a schema change, or a mock returning `{}` would silently appear as "LIVE" on the dashboard with all bars empty. **Fix:** minimum-viable-schema gate requires these 4 fields non-null: `5h-utilization`, `5h-reset`, `7d-utilization`, `7d-reset`. Any 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 non-null failure modes (no credentials, auth failure, rate limit, schema drift, network error) into `status: 'unavailable', reason: 'no public quota api or probe disabled'` — the same string as providers with no quota API at all. Operator could not tell what to fix. **Fix:** `quotaStatus()` v0.5.1 return contract: `null` reserved for opt-in-off only; probe failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. `aggregateProviderQuota()` emits new fields `failure_kind`, `failure`, `backoff_until` per row. `status: 'unreachable'` distinguishes "probe failed" from `status: 'unavailable'` ("no API or disabled"). Dashboard renders `unreachable` with a red border + failure.message + backoff countdown.
### Backwards-compat notes
- `quotaStatus()`: `stale: false` → now also includes `probe_status: 'live'` (additive). `stale: true` → now also includes `probe_status: 'stale'` + `failure: {...}` (additive). `null` → NOW RESERVED FOR OPT-IN-OFF ONLY (breaking for callers that relied on `null` to detect "no credentials" or "probe failed" — use `probe_status: 'unreachable'` instead).
- `ProviderQuotaEntry.status`: gains `'unreachable'` as a new value (additive). Existing `'live'`, `'stale'`, `'unavailable'` semantics unchanged.
- `ProviderQuotaEntry` gains new fields `failure`, `failure_kind`, `backoff_until` (additive, null when not applicable).
- `dashboard.html`: handles `unreachable` row (no existing row had this status; additive render path).
### Test changes
- 38f, 38j, 38l: updated assertions from `null` to `probe_status: 'unreachable'` (F3 shape change).
- 38r: refactored to seed cache + manually expire it + set backoff (F1 — doctor now routes through `quotaStatus()`). Added F1-regression assertion: HTTP call counter stays at 1 after two doctor calls within backoff.
- 38g, 38k: added `probe_status` + `failure` assertions (verify new fields present on live/stale shapes).
- **38u** (new): F1 regression — successive doctor calls within backoff window → HTTP counter stays at 1.
- **38v** (new): F2 regression — 200 + empty ratelimit headers → `probe_status: 'unreachable'` + `failure_kind: 'schema_drift'` + cache stays null.
- **38w** (new): F3 regression — `lastError` + `failureKind` propagate through `quotaStatus()` shape for all failure modes (rate_limited / auth_failed / schema_drift / no_credentials).
### ADR changes
- **ADR 0013 Rule 3** clarification: doctor checks route through `quotaStatus()`, not `_probeOnce()` directly.
- **ADR 0013 Rule 5** update: minimum-viable-schema gate specification (4 required fields; absence = schema_drift signal).
- **ADR 0008 Amendment 2**: richer `ProviderQuotaEntry` shape with `failure`/`failure_kind`/`backoff_until`; `probe_status` on `quotaStatus()` return; `unreachable` status semantics; `dashboard.html` unreachable rendering.
### Authority
ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency); ADR 0008 Amendment 2; ADR 0002 Amendment 8 (unchanged); codex review findings F1F3 (codex PR review on v0.5.0 close PR #57).
---
## v0.5.0 — 2026-05-26
**Phase 5 — Provider Quota Probes + Dashboard Enrichment.** OLP gains live subscription-quota observability for Anthropic Pro/Max subscribers, surfaced through a Claude.ai-style Plan Usage panel on the owner-only dashboard. The probe is opt-in, READ-ONLY, idempotent on failure, and 5-min-cached with 60s→3600s exponential backoff. Six D-days, seven PRs, zero blocking reviewer findings, no flaky tests; 720 → 756 total tests.
### What's new for users
- **Live plan usage on the dashboard.** Per-provider rows show 5-hour + 7-day utilization bars with reset countdowns ("Resets in 1hr 6min" / "Resets Sun 9:00 PM"), status badges (allowed / rejected), representative-claim chips ("five_hour" / "seven_day"), overage-status indicators, and a `↻ Refresh` button. 60-second auto-refresh pauses when the tab is hidden.
- **Anthropic quota probe.** Opt-in via `~/.olp/config.json providers.anthropic.quota_probe_enabled: true`. Parses the canonical `anthropic-ratelimit-unified-*` response-header schema (13 fields) from a minimal `POST /v1/messages` probe. Reuses the spawn-path OAuth credentials — env var → `~/.claude/.credentials.json` → macOS Keychain. Refresh-on-401, stale-cache-on-failure.
- **`olp doctor anthropic.quota_probe_reachable`.** New check surfaces probe health. Returns `status: ok` with parsed utilization when fresh, `warn` on stale cache, `fail` with `human_steps[]` auth-aware recipe (re-login via `claude setup-token` or wait-and-retry).
- **Provider matrix.** Anthropic ✅ live (13 fields). OpenAI ❌ no public quota API. Mistral ❌ no member-key-accessible quota endpoint (Admin API exists but org-admin-scoped, out of scope for trusted-LAN deployment per ADR 0011). All three pinned in `models-registry.json quota_probe.<provider>` block.
### What's new for contributors
- **ADR 0012 (Phase 5 charter)** — D-day plan + exit gate + scope boundaries (`docs/adr/0012-phase-5-charter-quota-probes-dashboard.md`).
- **ADR 0002 Amendment 8** — first Class-specific Exception to the plugin contract: `quotaStatus()` may call provider HTTP APIs directly, subject to three constraints (READ-ONLY, subscription-scope, idempotent-failure) and the per-endpoint enumeration in ADR 0013 Rule 2.
- **ADR 0013** — seven rules covering OAuth READ-ONLY consumption + dual-path schema-drift mitigation (compiled-binary `strings` + live API probe diff, since Claude Code v2.1.x is now a Mach-O / ELF binary with no `cli.js` to grep).
- **`models-registry.json quota_probe.schema_version`** — pinned at `2026-05-26` (13 fields). Bump on schema-drift events per ADR 0013 Rule 5.
- **Test seams** — 5 underscore-prefixed exports in `lib/providers/anthropic.mjs` (`_setQuotaUrlsForTest`, `_resetQuotaProbeStateForTest`, `_resetQuotaStateOnlyForTest`, `_getQuotaProbeStateForTest`, `_setQuotaAuthReadFnForTest`) for hermetic probe testing. Production code must not call them.
- **ALIGNMENT.md § Class-specific Exceptions** — gains its first numbered exception (Anthropic plan-usage probe via direct `/v1/messages`).
- **Audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — schema canon + verification protocol + OCP institutional history.
### D-day-level changes (Phase 5)
- **D79** (PR #50 + cleanup PR #51): governance layer — ADR 0012 charter, ADR 0002 Amendment 8, ADR 0013, ALIGNMENT.md Class-specific Exceptions entry, D84 Mistral NO-GO disposition.
- **D80** (PR #52): ported OCP `server.mjs:842-1109` to `lib/providers/anthropic.mjs:quotaStatus()`. Adds macOS-keychain reader to `readAuthArtifact()`. Parses all 13 fields including 3 new since OCP's 2026-04 capture (`5h-status`, `7d-status`, `overage-reset`). Implements 5min cache + 60s-3600s exponential refresh backoff + stale-cache-on-failure + opt-in config flag + `anthropic.quota_probe_reachable` doctor check. ~250 LOC.
- **D81** (PR #53): added `lib/audit-query.mjs aggregateProviderQuota()` + `/v0/management/dashboard-data quota_v2` field + `/v0/management/quota quota_v2` field. Pinned `quota_probe.schema_version` in `models-registry.json`. Legacy `quota` field stays alongside for backwards compat until v1.0.0. ADR 0008 Amendment 1 documents the shape.
- **D82** (PR #54): `dashboard.html` restructure — Claude.ai-style Plan Usage panel above the existing 4 panels. Per-provider rows with utilization bars, reset countdowns, status chips, representative-claim badges, overage chips, "Updated N min ago" labels. 60s `setInterval` with `visibilitychange` pause/resume. Manual refresh button with 2s spam guard. Graceful fallback to legacy `quota` when `quota_v2` absent. Closes v1.x roadmap #8.
- **D83** (PR #55): Suite 38 (20 quota-probe unit tests covering all 13-header parse + cache + backoff + 401-refresh + 429-stale + schema_version + 5 doctor status paths) + Suite 39 (8 dashboard rendering smoke tests covering /dashboard 200/401 + key D82 HTML strings). Added 5 test seams to anthropic.mjs. 727 → 755 tests, 0 fail. Fold-in commit added 38j positive-path coverage (38j2: 401 → refresh succeeds → retry 200) per reviewer finding; total 756.
- **Close-prep** (PR #56): README § Plan Usage section + § Supported Providers Quota-probe column + dashboard screenshot + `docs/exit-gates/phase-5-e2e.json` live verification artifact. Fold-in commit addressed 3 maintainer accuracy findings (doctor-kind framing / Mistral admin-API acknowledgment / SPOT drift closure via `quota_probe.openai` + `quota_probe.mistral` registry entries).
### Out of Phase 5 scope (deferred to later)
- **D84 Mistral probe.** NO-GO per 2026-05-26 spike: no member-key-accessible quota endpoint at `docs.mistral.ai/api`. Re-entry point pinned at `lib/providers/mistral.mjs DL-7`; re-evaluate if Mistral publishes a member-key surface or if OLP deployment posture expands to org-admin scope (Mistral Admin API exists).
- **OpenAI / codex probe.** Permanently skipped — `openai/codex` CLI has no public quota API.
- **`X-OLP-Cost-USD` per-request header.** Deferred to Phase 6 (depends on per-(provider, model) cost weights table).
- **`context_window_exceeded` fallback trigger.** Deferred (trigger condition not yet observed).
- **Automated schema-drift detector.** ADR 0013 Rule 5 codifies a procedural runbook (Annual Alignment Audit + `olp doctor` probe-failure + manual maintainer attention at major `claude --version` bumps), not an automated alarm.
### Authority cited
ALIGNMENT.md Rules 1 + 2 + 5; CLAUDE.md release_kit (Phase 5 close trigger); ADR 0012 § Exit gate; ADR 0013 Rule 5 schema-drift protocol; OCP `server.mjs:842-1109` as port reference; live `/v1/messages` probe transcripts captured 2026-05-26 from PI231 (D79 audit) + MacBook (D80 + Phase 5 close-prep E2E); audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
## v0.4.4 — 2026-05-26
### D78 — `bin/olp-connect` stale-strings cleanup + README CDN-safe URL + repo-visibility flip
Patch release on top of v0.4.3. Three small issues caught when running `olp-connect` for real on MacBook (D77 client-install verification):
- **G11 fix (repo visibility).** Repo `dtzp555-max/olp` flipped from PRIVATE → PUBLIC during this session, closing the original G11 finding (`bash <(curl -fsSL .../main/bin/olp-connect)` returned 404 because anonymous curl can't fetch from private repos). README's `/main/` URL works going forward; GitHub's raw CDN may serve a stale 404 for `/main/` for ~5-15min after the visibility flip due to negative caching. D78 defends against this by adding a **tag-pinned URL (`/v0.4.4/bin/olp-connect`) as the primary recommendation in README**, with `/main/` listed as an alternative for trusted-head users. Tag-pinned URLs bypass the negative-cache because the tag ref was never queried while the repo was private.
- **G12 fix (`detect_openclaw` claimed plugin not shipped).** `bin/olp-connect`'s OpenClaw detection block said `"The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED"` — but D71-D73 shipped `olp-plugin/` at v0.4.0. D78 replaces the stale text with real install instructions: `git clone` + `openclaw plugins install ./olp-plugin/` (or symlink), edit `~/.openclaw/openclaw.json` with a dedicated bot apiKey, restart gateway. Points at `docs/integrations/openclaw.md` for the full setup.
- **G13 fix (`olp-connect` self-version hardcoded literal).** Pre-D78 the script declared `OLP_CONNECT_VERSION="0.4.0-phase4"` as a hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3 (the maintain-the-literal-per-release pattern is reliably forgotten). D78 derives the version at runtime from the sibling `package.json` via python3 — when the script is invoked from a checked-out repo, version resolves to the actual `package.json` value; when invoked via `curl … | bash` with no on-disk package.json next to it, falls back to `unknown`. Now `bash bin/olp-connect --version` prints `olp-connect 0.4.4` automatically with no manual touch needed at the next release.
**Pre-publish audit.** Per `~/.cc-rules/docs/guides/pre-publish-audit.md` checklist (2026-05-26 session, before the visibility flip):
- Identity scrub: 0 hits (no personal names / hostnames / home paths / personal emails leaked into the working tree)
- Credential scrub: 0 real tokens — all `olp_` matches are placeholder (`olp_XXXX...`) or test fixtures (`olp_not-a-real-key-...`); gitleaks: "no leaks found"
- Git-history author emails: 78 commits, two emails (`dtzp555@gmail.com` local + `taodeng1977@gmail.com` GitHub-account squash-merges). Maintainer chose Option A (accept) — the GitHub-account email was already verified-public on the maintainer's GitHub profile, so the visibility flip exposes nothing new.
**Test count:** 717 (v0.4.3) → 720 (v0.4.4). +3 D78 regression tests in Suite 36:
- 36v — pins absence of `NOT YET SHIPPED` text + presence of real install path
- 36w — pins runtime version derivation from package.json (hardcoded literal gone)
- 36x — pins README's tag-pinned-URL recommendation
**Authority:** D77 MacBook client-install verification session (2026-05-26); `~/.cc-rules/docs/guides/pre-publish-audit.md`. Process learning: every README that includes a `curl <raw-URL> | bash` install pattern should pin to a release tag (not `/main/`) for CDN-cache resilience. The /main/ form is correct for the long-tail (when no negative cache exists) but the tag-pinned form survives the visibility-flip transient + survives any future force-push to main.
**Out of D78 scope:**
- F6 (doctor client-side vs server-side check separation) — Phase 5 ADR amendment.
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive `typeof hopModel === 'string'` invariant) — both genuine follow-ups, neither blocking.
- `scripts/migrate-from-ocp.mjs` — Phase 7.
## v0.4.3 — 2026-05-26
### D76 — README install-path overhaul + `OLP_BIND` env + AI-driven install prompt + ADR 0011 amendment
Patch release closing the install-experience gap. v0.4.0v0.4.2 README's Quick Start was placeholder text with fictional commands (`npm install -g @dtzp555-max/olp` — package isn't published; `olp setup` / `olp start` — don't exist). 10 real gaps catalogued + fixed in one D-day; `OLP_BIND` env wired so the documented LAN onboarding flow actually works; AI-driven install prompt added per the Phase 4 charter brainstorm's #2 OCP inheritance candidate (was deferred at D64-D67 to the doctor framework only; D76 closes the README half).
- **G1-G7 (README "Quick Start" was fictional)** — rewrote § "Manual install" with the real sequence: prerequisites (Node ≥ 18 + provider CLI install matrix) → `git clone``npm test` verify → `olp-keys keygen --owner` first → provider OAuth (claude/codex/mistral per-CLI flows) → write `~/.olp/config.json` with the minimum that actually serves traffic → `npm start` → smoke-test → IDE pointing. Each step empirically verified against the PI231 + Mac mini E2E session (2026-05-26).
- **G8 (LAN unreachable — F5)** — added `OLP_BIND` env (default `127.0.0.1`). Operators set `OLP_BIND=0.0.0.0` (or a specific LAN IP) to accept LAN connections so `olp-connect <ip>` can actually reach the server. Pre-D76 the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`, making the documented LAN-onboarding flow only usable through an SSH tunnel. ADR 0011's original wording referenced a `BIND_ADDRESS` concept that didn't exist; D76 makes it operational.
- **G10 (no AI-install pattern)** — README § "Install with your AI (the fast path)" added. Verbatim prompt that the operator pastes into Claude Code / Cursor / Copilot / Aider; the AI follows the README + uses `olp doctor --json` machine-readable `next_action.ai_executable[]` (D64-D67) for self-repair, stopping only when `human_required[]` is non-empty (the provider OAuth dances). This closes the Phase 4 brainstorm Top-5 inheritance candidate #2 — the OCP "paste this prompt" pattern that D64-D67 only half-built.
- **Opening compressed** — § "Why OLP" (3 paragraphs of OCP billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + § "Install with your AI" / § "Manual install" without needing to digest 2026-05-14 / 2026-06-15 Anthropic billing history first. OCP users get a one-line pointer at the top.
- **§ "Configuration" full schema documentation** — replaced the placeholder with the actual `~/.olp/config.json` schema including every field that v0.4.x reads. Cross-references ADR 0004/0007/0010/0011.
- **§ "Environment Variables" extended** — added `OLP_BIND`, `OLP_API_KEY`, `OLP_OWNER_TOKEN`, `OLP_PROXY_URL` rows that were used throughout the manual-install flow but undocumented.
**ADR 0011 § "Deployment configurations" amendment.** Codifies the three deployment trust contexts (`127.0.0.1` loopback / RFC1918 + tailnet LAN / `0.0.0.0` public — with `advertise_anonymous_key: true` only safe in the first two). Documents the new `anonymous_key_advertised_with_lan_bind` startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent `BIND_ADDRESS`.
**Test count:** 714 (v0.4.2) → 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning `OLP_BIND` wiring + safety warn + ADR amendment.
**Out of D76 scope (deferred):**
- F6 (doctor client-side vs server-side check separation) — needs design ADR for a `--remote` mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 amendment for per-hop schema) + P2-2 (defensive `typeof hopModel === 'string'`) — both genuine follow-ups, neither blocking.
- `scripts/migrate-from-ocp.mjs` — Phase 7.
**Authority:** PI231 + Mac mini E2E session (2026-05-26, post-v0.4.2 verification revealed the 10 README gaps); ADR 0011 amendment self-cites; Phase 4 charter (ADR 0010) Top-5 inheritance candidate #2 (AI-driven self-repair). Process learning: every D-day reviewer rubric should add "open README in §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0.
## v0.4.2 — 2026-05-26 ## v0.4.2 — 2026-05-26
+2 -2
View File
@@ -135,7 +135,7 @@ release_kit:
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently # This overlay is the authoritative source. If Iron Rule 5 appears to be silently
# violated (no version bump after many D-day pushes), check this section first # violated (no version bump after many D-day pushes), check this section first
# before filing a compliance finding. # before filing a compliance finding.
current_phase: Phase 5 current_phase: Phase 6
current_pre_release_identifier: "0.5.0-phase5" current_pre_release_identifier: "0.6.0-phase6"
phase_close_trigger: explicit maintainer action (not automated) phase_close_trigger: explicit maintainer action (not automated)
``` ```
+292 -55
View File
@@ -1,76 +1,230 @@
# OLP — Open LLM Proxy # OLP — Open LLM Proxy
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left. A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing + fallback + content-addressed caching. Your IDEs and family clients keep working as long as **any** of your subscriptions has quota left.
> **Status:** v0.4.0 shipped (2026-05-26) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI (v0.2.0) + Phase 3 Dashboard + audit query layer + daily audit rotation (v0.3.0) + Phase 4 Operator + Client UX (v0.4.0): SSE heartbeat / `olp` Node CLI + `olp doctor` framework / `olp-connect` zero-config LAN setup / `/health.anonymousKey` opt-in / `/olp` Telegram-Discord plugin / 6-IDE integration docs. Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope: `/v1/messages` (gated on ADR 0009 P0 outcome + named family CC user), context-window-exceeded fallback trigger, per-(provider, model) live stats. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)). > **Status:** v0.5.1 shipped, 759+ tests. Phase 5 (Quota Probes + Dashboard Enrichment) closed; Phase 6 next. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
--- ---
## Why OLP ## What you get
On 2026-05-14, Anthropic announced (effective 2026-06-15) that `claude -p`, the Agent SDK, and third-party agent traffic move out of the Pro/Max subscription pool into a separate fixed monthly Agent SDK Credit pool. [OCP](https://github.com/dtzp555-max/ocp), OLP's predecessor, was a proxy around a single CLI — its core assumption was *"subscription = unlimited within rate limits"*. That assumption breaks for Anthropic on the effective date. - **OpenAI-compatible** `/v1/chat/completions` endpoint — any IDE that speaks OpenAI (Cline / Continue.dev / Cursor / Aider) plugs in
- **Multi-provider chain** — primary fails / quota dies → automatically falls back to the next provider (anthropic ↔ codex ↔ mistral by default; risk-tier framework guards which ones get enabled)
The structural response is to stop relying on one provider's subscription terms remaining favourable. OLP spreads risk across multiple providers whose subscriptions still include CLI/programmatic use, routes intelligently between them, and caches aggressively so every request that does spawn a CLI counts. - **Content-addressed cache** — repeat requests don't re-spawn the CLI; streaming requests dedup via singleflight tee
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
OLP is **not**: a commercial multi-tenant SaaS; an enterprise gateway competing with LiteLLM / OpenCode / CLIProxyAPI on breadth; a model-capability router ("route to the smartest model" — you pick the model); a conversation-state store (your client handles that). - **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
- **AI-driven self-repair** — `olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
See [`ALIGNMENT.md`](./ALIGNMENT.md) for OLP's constitution and [`docs/adr/`](./docs/adr/) for the founding ADRs. - **Observability** — owner-only `/dashboard` (live Claude.ai-style plan-usage rows / 24h stats / 30d spend trend / top fallback chains)
- **Plan-usage probe** (Phase 5, v0.5.0) — opt-in per-provider quota probe for Anthropic Pro/Max subscriptions; parses the canonical `anthropic-ratelimit-unified-*` response headers, surfaces 5-hour + 7-day utilization with reset countdowns. See [§ Plan Usage](#plan-usage-live-quota-probe).
--- ---
## Quick Start ## Install with your AI (the fast path)
_placeholder — lands with Phase 1._ If the manual steps feel like a lot, paste this verbatim into your AI coding assistant (Claude Code / Cursor / Copilot / Aider). It walks you through everything:
Anticipated shape: ```
I want to install OLP (Open LLM Proxy) on this machine. The repo is at
https://github.com/dtzp555-max/olp and ships these tools that you can use:
```bash 1. `git clone` the repo to ~/olp
# install 2. README.md § "Manual install" has the sequenced steps. Follow them in order.
npm install -g @dtzp555-max/olp 3. After EACH step, run `node ~/olp/bin/olp.mjs doctor --json` (NOT `npx olp`
— the package isn't on npm). Parse the JSON. The `kind` field tells you
the state: noop / fresh_install / fix_server / fix_oauth / fix_provider /
fix_config.
4. If `kind != noop`, run the commands listed in `next_action.ai_executable[]`
verbatim. Then re-run doctor to verify.
5. STOP and ask me only when `next_action.human_required[]` is non-empty.
That's where I need to do a browser OAuth flow you can't do for me.
# run setup (writes ~/.olp/config.json, asks which providers to enable) The provider CLIs OLP spawns (claude / codex / vibe) need their own one-time
olp setup OAuth — those are the only steps I personally have to do (Claude.ai login,
ChatGPT login, Mistral API key). Everything else (clone, npm install of the
provider CLIs, owner-key generation, config.json bootstrap, server start) is
in your `ai_executable[]` and you should run it without asking.
# start the proxy (default port 4567 since v0.4.0 — moved off OCP's 3456 so Begin.
# OLP and OCP can co-host on the same machine. Set OLP_PORT=3456 if you have
# no OCP on the machine and want the old default.)
olp start
# point your IDE at http://localhost:4567/v1/chat/completions with the OLP API key from `olp keys list`.
``` ```
**Family-on-LAN onboarding (D68-D70).** For other devices on the same network, run on the client device: Then sit back and respond when it asks for OAuth confirmation. This pattern works because `olp doctor` is purpose-built for AI consumption — every failure mode has a shell-executable repair command AND a human-required step listed separately.
---
## Manual install (5-10 min)
### 0. Prerequisites
- **Node.js ≥ 18.** Verify: `node --version`
- **The provider CLIs you want OLP to spawn.** Install whichever you'll actually use:
| Provider | Install | Subscription |
|---|---|---|
| `anthropic` (`claude -p`) | `npm install -g @anthropic-ai/claude-code` | Claude Pro/Max (OAuth) |
| `openai` (`codex exec`) | `npm install -g @openai/codex` | ChatGPT Plus/Pro (OAuth) or OpenAI API key |
| `mistral` (`vibe --prompt`) | follow the `vibe` install docs | Le Chat Pro API key |
You only need to install the ones you'll route to. Single-provider OLP works fine.
### 1. Clone and verify the test suite
```bash ```bash
# Detects Cline / Continue.dev / Cursor / Aider / OpenClaw installed locally git clone https://github.com/dtzp555-max/olp.git ~/olp
# and writes per-tool config pointing at the OLP host. Requires `python3`. cd ~/olp
olp-connect <olp-host-ip> npm test # 714+ tests, ~5s, no external deps
``` ```
If the OLP host has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, `olp-connect` picks up the token from `/health.anonymousKey` — zero out-of-band token paste required. See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant. (If `npm test` fails here, stop — that means your Node version or the repo state is broken. Don't proceed to step 2.)
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). ### 2. Bootstrap the owner key
The owner key is what you (and `olp-connect`) use to authenticate to OLP. Default config has `auth.allow_anonymous: false`, so you need a key BEFORE the server starts accepting requests.
```bash
node ~/olp/bin/olp-keys.mjs keygen --owner --name=$(whoami)-laptop
# Prints the plaintext token ONCE. Copy it now — you can't recover it later.
# Example: olp_l23-PN46tDljmPATV94-KfOgOBO0Ed8theVjTdAgQoY
```
Export it so the CLI subcommands can use it:
```bash
export OLP_API_KEY=olp_l23-PN46... # paste your real token
```
(Add to `~/.bashrc` / `~/.zshrc` to persist.)
### 3. Authenticate the providers (one-time OAuth)
Run each provider's own login flow. OLP's anthropic / openai / mistral plugins spawn these CLIs and reuse their cached credentials — OLP itself never touches the OAuth dance.
```bash
# Anthropic (Claude Pro/Max subscription)
claude setup-token
# Opens a TUI / prints a URL. Authorize in browser. Paste the returned code.
# Result: ~/.claude/.credentials.json
# OpenAI (ChatGPT subscription)
codex login --device-auth
# Prints a https://auth.openai.com/codex/device URL + 10-char code.
# Open URL in browser, enter code, authorize.
# Result: ~/.codex/auth.json
# Mistral (Le Chat API key)
export MISTRAL_API_KEY=sk-... # add to ~/.bashrc to persist
```
### 4. Write a minimum config
`~/.olp/config.json`:
```json
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": [
"/health",
"/v0/management/dashboard-data",
"/v0/management/quota",
"/v0/management/status",
"/cache/stats",
"/dashboard"
],
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "anthropic": true, "openai": true }
},
"routing": {
"chains": {
"claude-sonnet-4-6": [
{ "provider": "anthropic", "model": "claude-sonnet-4-6" },
{ "provider": "openai", "model": "gpt-5.5" }
],
"gpt-5.5": [{ "provider": "openai", "model": "gpt-5.5" }]
}
},
"streaming": { "heartbeat_interval_ms": 15000 }
}
```
(Enable only the providers you actually authenticated in step 3. Chains map `<your-IDE's-requested-model>` → ordered list of `{provider, model}` hops; the chain's per-hop `model` is what gets passed to that provider's CLI.)
### 5. Start the server
```bash
cd ~/olp
npm start
# OLP v0.4.3 listening on :4567 (2 providers enabled)
```
### 6. Smoke-test
```bash
curl -H "Authorization: Bearer $OLP_API_KEY" http://localhost:4567/health | jq
# Expect: {ok: true, providers: {enabled: 2, status: {anthropic: {ok: true...}, openai: {ok: true...}}}}
node ~/olp/bin/olp.mjs doctor
# Expect: "9 of 9 checks passed", kind=noop
```
### 7. Point your IDE at OLP
```
OPENAI_BASE_URL=http://localhost:4567/v1
OPENAI_API_KEY=$OLP_API_KEY
```
Per-IDE configuration details: [`docs/integrations/`](./docs/integrations/README.md).
---
## Family / LAN setup
To let other devices on your home network use the same OLP server, you need TWO things:
1. **Bind to the LAN interface** (not just loopback). On the SERVER:
```bash
OLP_BIND=0.0.0.0 npm start # or your specific LAN IP, e.g. 192.168.1.10
```
Default is `127.0.0.1` (loopback only). See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — **never set `OLP_BIND=0.0.0.0` on a public-internet-facing host** (use a tunnel like Tailscale instead).
2. **Onboard each family member's device** from THEIR machine:
```bash
# Pinned to a known-good release (recommended — survives GitHub raw CDN cache hiccups):
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/v0.4.4/bin/olp-connect) <olp-host-ip>
# OR latest from main (use after v0.4.4 + once you trust head):
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect) <olp-host-ip>
```
Detects Cline / Continue.dev / Cursor / Aider / OpenClaw locally and writes per-tool config pointing at your OLP host. Requires `python3` on the client. Prompts for the OLP API key — OR, if the server has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, picks the token up from `/health.anonymousKey` (zero out-of-band paste). See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Telegram / Discord `/olp` slash command setup: [§ Telegram / Discord Usage](#telegram--discord-usage).
--- ---
## Supported Providers ## Supported Providers
Source of truth: [`models-registry.json`](./models-registry.json). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync. Source of truth: [`models-registry.json`](./models-registry.json). Per-provider columns are sourced from the registry's `providers.<key>` block (model metadata + tier) and `quota_probe.<key>` block (D81+; probe status / reason / source). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync.
OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned) from **Enabled Providers** (authority pin filled + plugin landed + Phase audit passed). The v0.1 founding commit ships **zero Enabled Providers** — enablement is a Phase audit deliverable, not a bootstrap claim. See [`ALIGNMENT.md` § Provider Inventory](./ALIGNMENT.md) for the transition gate. OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned) from **Enabled Providers** (authority pin filled + plugin landed + Phase audit passed). The v0.1 founding commit ships **zero Enabled Providers** — enablement is a Phase audit deliverable, not a bootstrap claim. See [`ALIGNMENT.md` § Provider Inventory](./ALIGNMENT.md) for the transition gate.
### Candidate Providers ### Candidate Providers
| Provider key | CLI | Subscription / auth | Anticipated Tier | Anticipated Phase | | Provider key | CLI | Subscription / auth | Quota probe (v0.5.0+) | Anticipated Tier | Anticipated Phase |
|---|---|---|---|---| |---|---|---|---|---|---|
| `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | D (re-eval post-2026-06-15) | Phase 1 | | `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | ✅ Live (13 `anthropic-ratelimit-unified-*` headers; opt-in via `quota_probe_enabled`) | D (re-eval post-2026-06-15) | Phase 1 |
| `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | D | Phase 2 | | `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | ❌ Not available (no public quota API) — audit-derived spend tracking only | D | Phase 2 |
| `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | D | Phase 3 | | `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26. Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) does expose billing / usage queries but requires an org-admin scope (out of scope for OLP family-tier deployment). Audit-derived spend tracking only at v0.5.0. | D | Phase 3 |
| `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | C | Phase 8+ | | `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | TBD (Phase 8+) | C | Phase 8+ |
| `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | C | Phase 8+ | | `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | TBD (Phase 8+) | C | Phase 8+ |
| `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | B | Phase 8+ | | `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | TBD (Phase 8+) | B | Phase 8+ |
| `glm` | TBD | Zhipu Coding Plan ($10+/mo) | B | Phase 8+ | | `glm` | TBD | Zhipu Coding Plan ($10+/mo) | TBD (Phase 8+) | B | Phase 8+ |
| `qwen` | TBD | Alibaba Coding Plan ($50/mo) | B | Phase 8+ | | `qwen` | TBD | Alibaba Coding Plan ($50/mo) | TBD (Phase 8+) | B | Phase 8+ |
**Risk tier guide.** D = permissive / safe (eligible for default-enabled); C = tightening signal, no enforcement history (opt-in); B = service-level key revocation risk (opt-in + consent); A = excluded by default (cannot be opt-in enabled). Tier B providers prompt for explicit consent on first enable and record consent in `~/.olp/config.json`. See [`ALIGNMENT.md` § Risk Tier Framework](./ALIGNMENT.md#risk-tier-framework). **Risk tier guide.** D = permissive / safe (eligible for default-enabled); C = tightening signal, no enforcement history (opt-in); B = service-level key revocation risk (opt-in + consent); A = excluded by default (cannot be opt-in enabled). Tier B providers prompt for explicit consent on first enable and record consent in `~/.olp/config.json`. See [`ALIGNMENT.md` § Risk Tier Framework](./ALIGNMENT.md#risk-tier-framework).
@@ -80,12 +234,19 @@ OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned)
## Configuration ## Configuration
_placeholder — full configuration reference lands with Phase 4 (fallback engine)._ OLP reads `~/.olp/config.json` at startup. § "[Manual install § Step 4](#4-write-a-minimum-config)" above has a working minimum example. The full schema:
OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
```json ```json
{ {
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": ["/health", "/dashboard", "/v0/management/..."],
"advertise_anonymous_key": false,
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "<provider-key>": true }
},
"routing": { "routing": {
"chains": { "chains": {
"<requested-model>": [ "<requested-model>": [
@@ -96,13 +257,78 @@ OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
"soft_triggers": { "soft_triggers": {
"<provider-key>": { "<trigger>": <threshold> } "<provider-key>": { "<trigger>": <threshold> }
} }
},
"streaming": {
"heartbeat_interval_ms": 0
} }
} }
``` ```
> **Note:** `routing.soft_triggers` thresholds are parsed and stored but have **no runtime effect at v0.1** — the quota polling path (`quotaStatus()` per hop) is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). The evaluation logic exists and is tested; only the production data ingestion path is deferred. Field guide:
Trigger types, fallback safety, idempotency rules, and the full example config land here when Phase 4 ships. See [ADR 0004 (Fallback Engine Semantics & Safety)](./docs/adr/0004-fallback-engine.md) for the design. - **`auth.allow_anonymous`** — default `false`. When false, every request needs a Bearer token; when true, anonymous-tier requests succeed (ADR 0007 § 7). Production posture is `false`.
- **`auth.owner_only_endpoints`** — list of endpoints that REQUIRE owner-tier auth (non-owner returns 401). The defaults above are minimum sane for production.
- **`auth.advertise_anonymous_key`** — default `false`. When true (+ `allow_anonymous: true` + a key created with `olp-keys keygen --anonymous --advertise`), `/health.anonymousKey` exposes the plaintext token so `olp-connect <ip>` is zero-config. **Trusted-LAN only** — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md).
- **`auth.fallback_detail_header_policy`** — controls `X-OLP-Fallback-Detail` response header emission. `owner_only` (default) only shows tuples to owner identity; debug surface to LAN family without leaking to anonymous.
- **`providers.enabled`** — flip a provider plugin on. Only enable providers whose CLI you've authenticated; OLP doesn't do its own OAuth.
- **`routing.chains`** — keyed by the model name your IDE / client requests. Each entry is an ordered list of fallback hops; each hop's `model` is what gets passed to that provider's CLI. F7 fix (D75) — the hop-level `model` field finally overrides the IR's request model during cross-provider fallback.
- **`routing.soft_triggers`** — parsed and stored but **inert at v0.4.x** — the `quotaStatus()` polling data path is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). Startup emits a warn if non-empty so the inert state is visible.
- **`streaming.heartbeat_interval_ms`** — default `0` (disabled). Set > 0 (e.g. `15000`) to emit SSE keepalive frames during silent windows. Required behind reverse proxies (nginx / Cloudflare Tunnel / Tailscale Funnel) with 60s idle aborts.
See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007 (Multi-key auth)](./docs/adr/0007-multi-key-auth.md), [ADR 0010 (Phase 4 charter)](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md), [ADR 0011 (Anonymous-key deployment)](./docs/adr/0011-anonymous-key-deployment-context.md).
---
## Plan Usage (live quota probe)
OLP v0.5.0+ surfaces live subscription quota for Anthropic Pro/Max subscribers on the owner-only `/dashboard`. Per-provider rows show 5-hour and 7-day utilization bars with reset countdowns, status badges, representative-claim hints, and a manual refresh button. The panel auto-refreshes every 60 seconds and pauses when the tab is hidden.
![OLP v0.5.0 dashboard — Plan Usage panel with live anthropic quota](./docs/img/dashboard-v0.5.0.png)
### How it works
The probe issues a minimal `POST /v1/messages` to `api.anthropic.com` (max_tokens: 1) using the same OAuth token Claude Code uses for `claude -p`. The body is discarded; only the 13 `anthropic-ratelimit-unified-*` response headers are parsed (5h/7d utilization + reset, status, representative-claim, fallback-percentage, overage status + disabled reason). Results cache for 5 minutes; refresh failures fall back to the previous cache marked `stale: true` while exponential backoff (60s → 3600s) protects against hammering the API.
See [ADR 0002 § Amendment 8](./docs/adr/0002-plugin-architecture.md), [ADR 0012 (Phase 5 charter)](./docs/adr/0012-phase-5-charter-quota-probes-dashboard.md), [ADR 0013 (OAuth READ-ONLY consumption + schema-drift mitigation)](./docs/adr/0013-oauth-read-only-consumption-and-schema-drift.md), and the schema pin at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
### Enabling the probe
The probe is **opt-in** (default off) per ADR 0013 Rule 4 — a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes. To enable, add to `~/.olp/config.json`:
```json
{
"providers": {
"anthropic": {
"enabled": true,
"quota_probe_enabled": true
}
}
}
```
The probe reads the OAuth token from (in order): `CLAUDE_CODE_OAUTH_TOKEN` env var, `~/.claude/.credentials.json`, macOS Keychain entry `"Claude Code-credentials"`. Make sure Claude Code is logged in (`claude setup-token` or equivalent) before opting in.
`olp doctor` adds a `anthropic.quota_probe_reachable` check when the probe is enabled. The check has `category: 'provider'`, so any failure (401/403 token-expiry, 429 rate-limit, network error) discriminates to `kind: fix_provider`. The `human_steps` recovery recipe inside the check distinguishes the underlying cause (re-login via `claude setup-token` for auth failures vs wait-and-retry for rate-limit) — the discriminator is uniformly `fix_provider` but the actionable text is auth-aware. Successful probes return `status: ok` with the parsed 5h / 7d utilization in the message body; stale-cache returns `status: warn`. Routing an auth-class failure to `kind: fix_oauth` (the other discriminator the framework supports) would require splitting this check across the `provider` / `auth` boundary — deferred to v1.x if `olp doctor` consumers report the ambiguity.
### Provider coverage
| Provider | Live quota probe | Path |
|---|---|---|
| `anthropic` | ✅ Live — 13 fields via `anthropic-ratelimit-unified-*` headers | This section |
| `openai` (codex) | ❌ Not available — `openai/codex` CLI has no public quota API | Falls back to audit-derived request counts |
| `mistral` | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys. Mistral's Admin API does expose billing / usage queries but is gated to org-admin scope and out of scope for OLP family-tier deployment. | Falls back to audit-derived request counts |
If Mistral ever publishes a usage endpoint, `lib/providers/mistral.mjs` DL-7 marks the re-entry point.
### Schema-drift protection
Claude Code v2.1.x is distributed as a compiled native binary (Mach-O on macOS, ELF on Linux) — the OCP-era "grep `cli.js`" verification no longer applies. OLP's replacement protocol (ADR 0013 § Rule 5):
1. `strings` over the platform-specific claude-code binary captures all hardcoded header names the binary expects.
2. A live `POST /v1/messages` against `api.anthropic.com` with valid OAuth captures what the server actually emits today.
3. Diff path 1 vs path 2 → the actionable schema delta.
This is re-run at every major `claude --version` bump (next trigger: v2.x → v3.x), at the Annual Alignment Audit (14 May), and whenever `olp doctor anthropic.quota_probe_reachable` returns an unexpected status code. The current pinned schema (13 fields, `2026-05-26`) lives in `models-registry.json` under `quota_probe.schema_version`.
--- ---
@@ -113,9 +339,9 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. | | `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. | | `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. | | `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. | | `/dashboard` | GET | 3 + 5 | ✅ Shipped (D50 + D51 + D82) | Owner-only multi-provider dashboard HTML. Phase 5 D82 adds a Claude.ai-style Plan Usage section at the top (per-provider utilization bars + reset countdowns + 60s auto-refresh + manual refresh button) on top of the existing four panels (24h request stats / 30d spend trend / top fallback chains / legacy quota fallback). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. | | `/v0/management/dashboard-data` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | JSON aggregate consumed by the dashboard polls. Shape `{ generated_at, window_24h, cache_hit_24h, quota, quota_v2, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. The new `quota_v2` field (D81) is the normalized per-provider shape consumed by the Plan Usage UI; the legacy `quota` field stays alongside for backwards compatibility until v1.0.0. Owner-only_block. |
| `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. | | `/v0/management/quota` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | Per-provider quota snapshot via `provider.quotaStatus()`. Includes both legacy `quota` and new `quota_v2` shape (mirrors `dashboard-data` for scripted monitoring). Owner-only_block. |
| `/cache/stats` | GET | 3 | ✅ Shipped (D50) | Live in-memory `cacheStore.stats()` (`{ hits, misses, size, inflightCount }` + `generated_at`). Owner-only_block. | | `/cache/stats` | GET | 3 | ✅ Shipped (D50) | Live in-memory `cacheStore.stats()` (`{ hits, misses, size, inflightCount }` + `generated_at`). Owner-only_block. |
--- ---
@@ -127,6 +353,10 @@ _placeholder — full table lands per-phase as variables are introduced._
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. | | `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
| `OLP_BIND` | `127.0.0.1` | HTTP listener bind address. **Set to `0.0.0.0` or your LAN IP to accept LAN connections** (required for `olp-connect <ip>` to actually reach the server). Default loopback-only is the secure default. See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — never bind to a public-internet IP. |
| `OLP_API_KEY` | (none) | Owner-tier OLP API key (the `olp_...` plaintext from `olp-keys keygen --owner`) used by `olp` CLI subcommands as the bearer for management endpoints. |
| `OLP_OWNER_TOKEN` | (none) | Fallback used by `olp` CLI if `OLP_API_KEY` is absent. |
| `OLP_PROXY_URL` | `http://127.0.0.1:$OLP_PORT` | Override target URL for `olp` CLI subcommands (so the same binary works against a remote OLP via SSH tunnel or direct LAN). |
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. | | `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). | | `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). | | `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
@@ -254,7 +484,7 @@ Use a dedicated bot key — not the maintainer's personal owner key — so revoc
## Implementation status (as of 2026-05-26, post-v0.4.0) ## Implementation status (as of 2026-05-26, post-v0.4.0)
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope. This table reflects what is currently shipped vs. what is designed for later phases. Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 closed at v0.5.0 (Quota Probes + Dashboard Enrichmentlive Anthropic plan-usage probe + Claude.ai-style dashboard + audit-query aggregateProviderQuota); v0.5.1 hotfix (quota probe cache/backoff/schema-drift correctness — codex review findings F1F3). Phase 6 is next. This table reflects what is currently shipped vs. what is designed for later phases.
| File / artifact | Status | Notes | | File / artifact | Status | Notes |
|---|---|---| |---|---|---|
@@ -343,7 +573,8 @@ The original v0.1 spec (in `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the
- **Phase 1** — Multi-provider proxy core: `server.mjs`, IR, three Tier-D provider plugins (Anthropic / OpenAI Codex / Mistral Vibe), cache (D1+D4) + cleanup (D2 bypass / D3 chunked replay / D23 size cap), fallback engine with first-chunk safety + hard triggers + per-hop log observability, IR↔OpenAI translation under Rule 2(b). ✅ Shipped — v0.1.0 (2026-05-24) + v0.1.1 cleanup (2026-05-25, D35D42). - **Phase 1** — Multi-provider proxy core: `server.mjs`, IR, three Tier-D provider plugins (Anthropic / OpenAI Codex / Mistral Vibe), cache (D1+D4) + cleanup (D2 bypass / D3 chunked replay / D23 size cap), fallback engine with first-chunk safety + hard triggers + per-hop log observability, IR↔OpenAI translation under Rule 2(b). ✅ Shipped — v0.1.0 (2026-05-24) + v0.1.1 cleanup (2026-05-25, D35D42).
- **Phase 2** — Multi-key auth (`lib/keys.mjs`) per ADR 0007: opaque OLP API keys, per-key cache namespacing, owner-vs-guest tier for header gating, audit ndjson (`lib/audit.mjs`), `/health` payload trimming + `X-OLP-Fallback-Detail` emission gating, `OLP_OWNER_TOKEN` env override, keygen CLI (`bin/olp-keys.mjs`). ✅ Shipped — v0.2.0 (2026-05-25, D43-A → D47). All 11 ADR 0007 § 10 acceptance criteria covered. - **Phase 2** — Multi-key auth (`lib/keys.mjs`) per ADR 0007: opaque OLP API keys, per-key cache namespacing, owner-vs-guest tier for header gating, audit ndjson (`lib/audit.mjs`), `/health` payload trimming + `X-OLP-Fallback-Detail` emission gating, `OLP_OWNER_TOKEN` env override, keygen CLI (`bin/olp-keys.mjs`). ✅ Shipped — v0.2.0 (2026-05-25, D43-A → D47). All 11 ADR 0007 § 10 acceptance criteria covered.
- **Phase 3** — Dashboard + audit query layer + daily audit rotation per ADR 0008: in-memory ndjson aggregate query layer (`lib/audit-query.mjs`), 4 owner-only_block management endpoints (`/dashboard` + `/v0/management/dashboard-data` + `/v0/management/quota` + `/cache/stats`), multi-panel `dashboard.html` with 30s poll, synchronous daily audit rotation + `bin/olp-audit-rotate.mjs` cron tool, `tried_providers` schema fix (D45 P2 deferral). ✅ Shipped — v0.3.0 (2026-05-25, D48 → D54). All 15 ADR 0008 § 10 acceptance criteria covered. - **Phase 3** — Dashboard + audit query layer + daily audit rotation per ADR 0008: in-memory ndjson aggregate query layer (`lib/audit-query.mjs`), 4 owner-only_block management endpoints (`/dashboard` + `/v0/management/dashboard-data` + `/v0/management/quota` + `/cache/stats`), multi-panel `dashboard.html` with 30s poll, synchronous daily audit rotation + `bin/olp-audit-rotate.mjs` cron tool, `tried_providers` schema fix (D45 P2 deferral). ✅ Shipped — v0.3.0 (2026-05-25, D48 → D54). All 15 ADR 0008 § 10 acceptance criteria covered.
- **Phase 4 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend. - **Phase 5** — Live quota probe (Anthropic Pro/Max OAuth plan-usage via `anthropic-ratelimit-unified-*` headers), Claude.ai-style dashboard enrichment (utilization bars + reset countdowns), audit-query `aggregateProviderQuota()`, per-provider quota_v2 shape in dashboard-data. ✅ Shipped — v0.5.0 (2026-05-27). v0.5.1 hotfix (2026-05-27): quota probe cache/backoff/schema-drift correctness (codex review findings F1F3).
- **Phase 6 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend.
- **Phase 4+ (v1.x roadmap, triggered as needed)** — Full deferred-work tracker: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md). Includes streaming-path singleflight ([issue #16](https://github.com/dtzp555-max/olp/issues/16) + ADR 0005 Amendment 8 design ratified), soft-trigger reactivation (ADR 0004 Amendment 2), `/health` activeSpawns integration, provider-level `cacheKeyFields` mask, streaming-path SPAWN_FAILED salvage. - **Phase 4+ (v1.x roadmap, triggered as needed)** — Full deferred-work tracker: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md). Includes streaming-path singleflight ([issue #16](https://github.com/dtzp555-max/olp/issues/16) + ADR 0005 Amendment 8 design ratified), soft-trigger reactivation (ADR 0004 Amendment 2), `/health` activeSpawns integration, provider-level `cacheKeyFields` mask, streaming-path SPAWN_FAILED salvage.
- **Phase N (opt-in)** — Tier-2 / Tier-C provider plugins (Grok / Kimi / MiniMax / GLM / Qwen) per [ADR 0006](./docs/adr/0006-provider-inclusion.md); provider-native protocol endpoints; deterministic triggers. Triggered by tier-2 demand, not on the bootstrap path. - **Phase N (opt-in)** — Tier-2 / Tier-C provider plugins (Grok / Kimi / MiniMax / GLM / Qwen) per [ADR 0006](./docs/adr/0006-provider-inclusion.md); provider-native protocol endpoints; deterministic triggers. Triggered by tier-2 demand, not on the bootstrap path.
@@ -353,16 +584,22 @@ Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/proje
## Migration from OCP ## Migration from OCP
OLP is OCP's successor. The trigger was Anthropic's 2026-05-14 announcement (effective 2026-06-15) splitting `claude -p` / Agent SDK / third-party agent traffic out of the Pro/Max subscription pool into a separate fixed $100/month Agent SDK credit pool — invalidating OCP's foundational assumption (*"subscription = unlimited within rate limits"*) for its only provider. OLP's structural response is to spread risk across multiple subscriptions whose CLI/programmatic use remains in their main subscription pool, with intelligent fallback when one runs out.
Beyond the billing trigger, OLP is intentionally NOT a commercial multi-tenant SaaS (LiteLLM / OpenRouter / Portkey already serve that market with funding + SOC2), NOT an enterprise gateway competing on provider breadth, NOT a model-capability router ("route to the smartest model" — you pick the model in `routing.chains`), and NOT a conversation-state store (your client manages its own context). See [ADR 0001](./docs/adr/0001-project-founding.md) for the founding decision and [`ALIGNMENT.md`](./ALIGNMENT.md) for the constitution that governs every plugin / IR / entry-surface change.
### Migrating an existing OCP install
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._ _placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
Anticipated user-facing flow (target: <5 minutes): Anticipated user-facing flow (target: <5 minutes):
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`). 1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
2. Install OLP. 2. Install OLP (per [§ Manual install](#manual-install-5-10-min) above).
3. Run `olp migrate-from-ocp` — copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable. 3. Run `olp migrate-from-ocp` — will copy `~/.ocp/keys/` to `~/.olp/keys/` and point provider plugins at OCP's existing auth artifacts where applicable.
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid. **Note (v0.4.0+):** default port moved from 3456 → 4567 so OCP and OLP can co-host during migration; set `OLP_PORT=3456` if you want the pre-D60 default. 4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid.
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP. **Default port moved 3456 → 4567 at v0.4.0** so OCP and OLP can co-host on the same machine during the migration window — set `OLP_PORT=3456` if you want the pre-D60 default. OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
--- ---
+47 -8
View File
@@ -29,7 +29,35 @@
set -euo pipefail set -euo pipefail
OLP_CONNECT_VERSION="0.4.0-phase4" # D78 (G13): derive version from package.json instead of hardcoding (was
# stuck at "0.4.0-phase4" through v0.4.1/v0.4.2/v0.4.3 because no one
# updated it). Look up package.json next to the script if available;
# fall back to "unknown" when running curl-piped (no on-disk package.json).
_resolve_version() {
local script_dir pkg
# When curl-piped (`curl ... | bash`), BASH_SOURCE[0] is empty → dirname
# yields "." → script_dir resolves to cwd. D78 reviewer P2-1 hardening:
# require the suffix-strip to actually fire (script_dir ENDED with /bin),
# otherwise we'd happily pick up an unrelated package.json from whatever
# directory the user happens to be in when piping. Belt-and-braces.
# ${BASH_SOURCE[0]:-} default-empty guards against `set -u` nounset error
# when invoked via `curl ... | bash` (no source file → BASH_SOURCE unset).
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-}")" &>/dev/null && pwd)"
if [[ "$script_dir" != */bin ]]; then
echo "unknown"
return
fi
pkg="${script_dir%/bin}/package.json"
# D78 reviewer P2-2: pass $pkg via env var instead of -c interpolation
# so paths with apostrophes / shell metacharacters can't break the
# python invocation. Canonical layout is safe; this is defense-in-depth.
if [[ -f "$pkg" ]] && command -v python3 >/dev/null 2>&1; then
OLP_PKG_PATH="$pkg" python3 -c 'import json,os;print(json.load(open(os.environ["OLP_PKG_PATH"])).get("version","unknown"))' 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
OLP_CONNECT_VERSION="$(_resolve_version)"
show_version() { show_version() {
echo "olp-connect $OLP_CONNECT_VERSION" echo "olp-connect $OLP_CONNECT_VERSION"
@@ -246,17 +274,28 @@ detect_aider() {
fi fi
} }
# Detect OpenClaw. Per Phase 4 D71-D73 (NOT in this PR), olp will ship # Detect OpenClaw. Phase 4 D71-D73 shipped olp-plugin/ as the OpenClaw
# olp-plugin/ for OpenClaw with full Telegram/Discord /olp slash commands. # gateway plugin for /olp Telegram + Discord slash commands. Point users
# Until that ships, we just announce detection and link. # at the install path.
detect_openclaw() { detect_openclaw() {
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
log_info "" log_info ""
log_info "Detected: OpenClaw" log_info "Detected: OpenClaw"
log_info " The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED." log_info " OLP ships an OpenClaw gateway plugin for /olp Telegram + Discord"
log_info " When it ships, install with: openclaw plugin install olp" log_info " slash commands (status / usage / cache / models / providers /"
log_info " For now, you can manually point OpenClaw at OLP via the OPENAI_BASE_URL" log_info " chain show / health / doctor). Read-only by design — no chat-side"
log_info " env var (already written to your shell rc above)." log_info " mutations."
log_info ""
log_info " Install the plugin (one-time, on the host running OpenClaw):"
log_info " git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo"
log_info " openclaw plugins install /tmp/olp-repo/olp-plugin"
log_info " # OR symlink: ln -sf /tmp/olp-repo/olp-plugin ~/.openclaw/extensions/olp"
log_info ""
log_info " Then edit ~/.openclaw/openclaw.json to set the plugin apiKey to a"
log_info " dedicated OLP key (NOT your owner key — create one via olp-keys"
log_info " keygen --name <bot-name>). Restart OpenClaw gateway."
log_info ""
log_info " See docs/integrations/openclaw.md for full instructions."
fi fi
} }
+575 -24
View File
@@ -1,16 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- <!--
OLP Dashboard — Phase 3 / D51 OLP Dashboard — Phase 5 / D82
------------------------------ ------------------------------
Multi-panel owner-only dashboard per ADR 0008 § 6. Polls Multi-panel owner-only dashboard per ADR 0008 § 6.
/v0/management/dashboard-data every 30 seconds (paused when the
page is hidden via document.visibilityState).
Panels (per spec v0.1 § 4.6 + ADR 0008 Lane 5 = B full): Panels:
1. Per-provider quota / credit pool 0. Plan Usage (new D82 — Claude.ai-style per-provider rows; quota_v2; 1-min refresh)
2. Per-provider 24h request count + cache hit rate + fallback rate 1. Per-provider quota / credit pool (legacy; kept for graceful fallback when quota_v2 absent)
3. 30-day spend trend (SVG sparkline; per-provider in tooltip) 2. Per-provider 24h request count + cache hit rate + fallback rate (30s refresh)
4. Top 10 fallback chains by trigger count 3. 30-day spend trend (SVG sparkline; per-provider in tooltip) (30s refresh)
4. Top 10 fallback chains by trigger count (30s refresh)
Refresh cadence:
- Plan Usage panel: 60s (separate timer; visibilityState-guarded per ADR 0012 D82)
- Other panels: 30s (original poll cadence; paused when tab hidden)
Authority:
- ADR 0008 § 6 — dashboard layout + owner-only_block
- ADR 0012 D82 — quota_v2 Claude.ai-style restructure
- v1.x roadmap #8 — closed by this D-day
No build step, no framework, no external dependencies. Vanilla JS + No build step, no framework, no external dependencies. Vanilla JS +
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
@@ -43,15 +51,243 @@
.chain { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem; color: #374151; } .chain { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem; color: #374151; }
.pill { display: inline-block; background: #e5e7eb; color: #374151; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.75rem; } .pill { display: inline-block; background: #e5e7eb; color: #374151; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.75rem; }
footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; } footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; }
/* ───────────────────────────────────────────
Plan Usage panel — D82 Claude.ai-style rows
─────────────────────────────────────────── */
.plan-usage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.plan-usage-header h2 { margin: 0; }
.plan-usage-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.8rem;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.refresh-btn:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
.refresh-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.refresh-btn .spin { display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.provider-row {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
background: #fff;
}
.provider-row:last-child { margin-bottom: 0; }
.provider-row.unavailable { background: #f9fafb; }
.provider-row.stale { border-color: #fcd34d; }
.provider-row.unreachable { border-color: #fca5a5; background: #fff5f5; }
.provider-row-top {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.provider-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
}
.provider-badge.anthropic { background: #cc4b24; }
.provider-badge.codex { background: #10a37f; }
.provider-badge.mistral { background: #6d5acd; }
.provider-badge.openai { background: #10a37f; }
.provider-badge.default { background: #6b7280; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.live { background: #10b981; }
.status-dot.stale { background: #f59e0b; }
.status-dot.unavailable { background: #9ca3af; }
.status-dot.unreachable { background: #ef4444; }
.status-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 99px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.status-chip.live { background: #d1fae5; color: #065f46; }
.status-chip.stale { background: #fef3c7; color: #92400e; }
.status-chip.unavailable { background: #f3f4f6; color: #6b7280; }
.status-chip.unreachable { background: #fee2e2; color: #991b1b; }
.chip-sm {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
color: #374151;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.schema-tag {
margin-left: auto;
font-size: 0.7rem;
color: #9ca3af;
}
.unavailable-reason {
font-size: 0.875rem;
color: #9ca3af;
font-style: italic;
padding: 0.25rem 0 0;
}
.unreachable-reason {
font-size: 0.875rem;
color: #b91c1c;
font-style: italic;
padding: 0.25rem 0 0;
}
.last-fresh-tag {
font-size: 0.7rem;
color: #9ca3af;
}
.utilization-bars { display: flex; flex-direction: column; gap: 0.6rem; }
.util-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.util-label {
flex: 0 0 180px;
font-size: 0.8rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.util-label { flex: 0 0 100%; }
.util-row { flex-direction: column; align-items: flex-start; }
.grid { grid-template-columns: 1fr; }
}
.util-bar-wrap {
flex: 1 1 120px;
min-width: 80px;
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
}
.util-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.3s ease;
}
.util-bar-fill.green { background: linear-gradient(90deg, #34d399, #10b981); }
.util-bar-fill.amber { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.util-bar-fill.red { background: linear-gradient(90deg, #f87171, #ef4444); }
.util-pct {
flex: 0 0 40px;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #374151;
text-align: right;
}
.util-reset {
flex: 0 0 auto;
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
}
.rep-claim-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-weight: 600;
}
.overage-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.overage-chip.allowed {
background: #d1fae5;
color: #065f46;
border-color: #6ee7b7;
}
.provider-row-bottom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
flex-wrap: wrap;
}
</style> </style>
</head> </head>
<body> <body>
<h1>OLP Dashboard</h1> <h1>OLP Dashboard</h1>
<div id="meta" class="meta">Loading…</div> <div id="meta" class="meta">Loading…</div>
<div id="banner-slot"></div> <div id="banner-slot"></div>
<!-- Plan Usage panel (D82 — Claude.ai-style; full width) -->
<section class="panel" style="max-width: 1200px; margin-bottom: 1rem;">
<div class="plan-usage-header">
<h2>Plan Usage</h2>
<div class="plan-usage-meta">
<span id="quota-last-refresh"></span>
<button class="refresh-btn" id="quota-refresh-btn" title="Refresh quota data">
<span id="quota-refresh-icon"></span> Refresh
</button>
</div>
</div>
<div id="panel-plan-usage"><div class="panel-loading">Loading…</div></div>
</section>
<div class="grid"> <div class="grid">
<section class="panel"> <section class="panel" id="legacy-quota-section" style="display:none;">
<h2>Quota (per provider)</h2> <h2>Quota (per provider) — legacy</h2>
<div id="panel-quota"><div class="panel-loading">Loading…</div></div> <div id="panel-quota"><div class="panel-loading">Loading…</div></div>
</section> </section>
<section class="panel"> <section class="panel">
@@ -67,15 +303,21 @@
<div id="panel-chains"><div class="panel-loading">Loading…</div></div> <div id="panel-chains"><div class="panel-loading">Loading…</div></div>
</section> </section>
</div> </div>
<footer>OLP Dashboard · poll every 30s · paused when tab hidden · v0.3.0-phase3</footer> <footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.1</footer>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
const POLL_INTERVAL_MS = 30000;
let pollHandle = null;
/* ─────────────── constants ─────────────── */
const POLL_INTERVAL_MS = 30000; // 30s for legacy panels
const QUOTA_POLL_INTERVAL_MS = 60000; // 60s for Plan Usage (D82)
let pollHandle = null;
let quotaRefreshTimer = null;
/* ─────────────── DOM helpers ─────────────── */
function fmtNum(n) { return (n ?? 0).toLocaleString(); } function fmtNum(n) { return (n ?? 0).toLocaleString(); }
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; } function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
function el(tag, attrs, ...children) { function el(tag, attrs, ...children) {
const node = document.createElement(tag); const node = document.createElement(tag);
if (attrs) for (const [k, v] of Object.entries(attrs)) { if (attrs) for (const [k, v] of Object.entries(attrs)) {
@@ -97,6 +339,223 @@
return node; return node;
} }
/* ─────────────── Reset countdown helper (D82 § B) ─────────────── */
/**
* formatResetCountdown(epochSeconds) → human-readable string
*
* - past: "Resetting now…"
* - < 1 hour: "Resets in 23 min"
* - < 24 hours: "Resets in 12hr 30min"
* - < 7 days: "Resets Sun 9:00 PM"
* - >= 7 days: "Resets May 31 9:00 PM"
*/
function formatResetCountdown(epochSeconds) {
if (epochSeconds == null) return '—';
const nowMs = Date.now();
const targetMs = epochSeconds * 1000;
const diffMs = targetMs - nowMs;
if (diffMs <= 0) return 'Resetting now…';
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 60) {
return 'Resets in ' + diffMin + ' min';
}
if (diffHr < 24) {
const remMin = diffMin - diffHr * 60;
if (remMin === 0) return 'Resets in ' + diffHr + 'hr';
return 'Resets in ' + diffHr + 'hr ' + remMin + 'min';
}
// Format as "Resets <day-of-week> <time>" or "Resets <month> <day> <time>"
const target = new Date(targetMs);
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
if (diffDay < 7) {
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
return 'Resets ' + dayStr + ' ' + timeStr;
}
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
return 'Resets ' + dateStr + ' ' + timeStr;
}
/* ─────────────── "Updated N min ago" helper ─────────────── */
function formatAgo(epochMs) {
if (epochMs == null) return '';
const diffMs = Date.now() - epochMs;
if (diffMs < 0) return 'just now';
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Updated just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin === 1) return 'Updated 1 min ago';
if (diffMin < 60) return 'Updated ' + diffMin + ' min ago';
const diffHr = Math.floor(diffMin / 60);
if (diffHr === 1) return 'Updated ~1hr ago';
return 'Updated ~' + diffHr + 'hr ago';
}
/* ─────────────── Utilization bar color ─────────────── */
function utilizationColor(fraction) {
if (fraction == null) return 'green';
if (fraction >= 0.80) return 'red';
if (fraction >= 0.50) return 'amber';
return 'green';
}
/* ─────────────── Provider badge color class ─────────────── */
function providerBadgeClass(name) {
const n = (name || '').toLowerCase();
if (n === 'anthropic') return 'anthropic';
if (n === 'codex') return 'codex';
if (n === 'mistral') return 'mistral';
if (n === 'openai') return 'openai';
return 'default';
}
/* ─────────────── Plan Usage renderer (quota_v2) ─────────────── */
function renderPlanUsage(quotaV2) {
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
if (!Array.isArray(quotaV2) || quotaV2.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No quota data available.'));
return;
}
const frag = document.createDocumentFragment();
for (const entry of quotaV2) {
const status = entry.status || 'unavailable';
const rowEl = el('div', { class: 'provider-row ' + status });
/* ── top bar: badge + status dot + chips + schema tag ── */
const topBar = el('div', { class: 'provider-row-top' });
topBar.appendChild(el('span', { class: 'provider-badge ' + providerBadgeClass(entry.provider) }, (entry.provider || '').toUpperCase()));
topBar.appendChild(el('span', { class: 'status-dot ' + status, title: 'Status: ' + status }));
topBar.appendChild(el('span', { class: 'status-chip ' + status }, status));
if (entry.schema_version) {
topBar.appendChild(el('span', { class: 'schema-tag' }, 'schema: ' + entry.schema_version));
}
rowEl.appendChild(topBar);
/* ── unavailable: just show reason, no bars ── */
if (status === 'unavailable') {
const reason = entry.reason || 'no public quota api or probe disabled';
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, reason));
frag.appendChild(rowEl);
continue;
}
/* ── unreachable (v0.5.1): probe failed + no cache — show failure detail ── */
if (status === 'unreachable') {
const failure = entry.failure || {};
const kind = failure.kind || 'unknown';
const msg = failure.message || 'probe failed — no cached data available';
const shortText = `${kind}: ${msg}`;
rowEl.appendChild(el('div', { class: 'unreachable-reason' }, shortText));
if (failure.backoff_until) {
const backoffMs = Math.max(0, failure.backoff_until - Date.now());
const backoffSec = Math.round(backoffMs / 1000);
if (backoffSec > 0) {
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, `backoff active: ${backoffSec}s remaining`));
}
}
frag.appendChild(rowEl);
continue;
}
/* ── utilization bars (5h + 7d) ── */
const util = entry.utilization || {};
const reset = entry.reset || {};
const barsWrap = el('div', { class: 'utilization-bars' });
const windows = [
{ key: '5h', label: 'Current 5-hour session' },
{ key: '7d', label: 'Weekly all-models' },
];
for (const w of windows) {
const frac = util[w.key];
const resetEpoch = reset[w.key];
const color = utilizationColor(frac);
const pctStr = frac != null ? Math.round(frac * 100) + '%' : '—';
const fillPct = frac != null ? Math.min(100, Math.round(frac * 100)) : 0;
const resetStr = formatResetCountdown(resetEpoch);
const utilRow = el('div', { class: 'util-row' });
utilRow.appendChild(el('span', { class: 'util-label', title: w.label },
w.label + (frac != null ? ': ' + pctStr : '')
));
const barWrap = el('div', { class: 'util-bar-wrap' });
barWrap.appendChild(el('div', {
class: 'util-bar-fill ' + color,
style: 'width: ' + fillPct + '%',
'aria-valuenow': fillPct,
'aria-valuemin': '0',
'aria-valuemax': '100',
role: 'progressbar',
}));
utilRow.appendChild(barWrap);
utilRow.appendChild(el('span', { class: 'util-pct' }, pctStr));
utilRow.appendChild(el('span', { class: 'util-reset' }, resetStr));
barsWrap.appendChild(utilRow);
}
rowEl.appendChild(barsWrap);
/* ── bottom chips: representative-claim, overage, last-fresh ── */
const bottomBar = el('div', { class: 'provider-row-bottom' });
if (entry.representative_claim) {
const claimLabel = entry.representative_claim === 'five_hour' ? '5-hour claim'
: entry.representative_claim === 'seven_day' ? '7-day claim'
: entry.representative_claim;
bottomBar.appendChild(el('span', { class: 'rep-claim-badge', title: 'Binding window: ' + entry.representative_claim }, claimLabel));
}
if (entry.overage && entry.overage.status) {
const ov = entry.overage;
const ovStatus = (ov.status || 'unknown').toLowerCase();
const isAllowed = ovStatus === 'allowed' || ovStatus === 'active';
const chipClass = isAllowed ? 'overage-chip allowed' : 'overage-chip';
const label = 'Overage: ' + (ov.status || '—')
+ (ov.disabled_reason ? ' (' + ov.disabled_reason + ')' : '');
bottomBar.appendChild(el('span', { class: chipClass, title: label }, label));
}
if (entry.fallback_percentage != null) {
const fpPct = Math.round(entry.fallback_percentage * 100) + '%';
bottomBar.appendChild(el('span', { class: 'chip-sm', title: 'Fallback rate (last window)' }, 'Fallback ' + fpPct));
}
if (entry.last_fresh_at) {
bottomBar.appendChild(el('span', { class: 'last-fresh-tag' }, formatAgo(entry.last_fresh_at)));
}
if (status === 'stale') {
const staleTitle = entry.last_fresh_at
? 'Last successful probe was ' + formatAgo(entry.last_fresh_at) + '; backoff active'
: 'Probe data is stale; backoff active';
bottomBar.appendChild(el('span', { class: 'chip-sm', style: 'color: #92400e; background: #fef3c7; border-color: #fcd34d;', title: staleTitle }, '⚠ stale data'));
}
rowEl.appendChild(bottomBar);
frag.appendChild(rowEl);
}
target.appendChild(frag);
}
/* ─────────────── Legacy quota renderer (graceful fallback) ─────────────── */
function renderQuota(data) { function renderQuota(data) {
const target = document.getElementById('panel-quota'); const target = document.getElementById('panel-quota');
target.innerHTML = ''; target.innerHTML = '';
@@ -129,6 +588,28 @@
target.appendChild(table); target.appendChild(table);
} }
/* ─────────────── Plan Usage top-level render + quota routing ─────────────── */
function renderQuotaSection(data) {
const hasV2 = Array.isArray(data.quota_v2) && data.quota_v2.length > 0;
const legacySection = document.getElementById('legacy-quota-section');
if (hasV2) {
// D82: use enriched quota_v2 rows; hide legacy panel
legacySection.style.display = 'none';
renderPlanUsage(data.quota_v2);
} else {
// Graceful fallback: show legacy quota panel (older server build without D81)
legacySection.style.display = '';
// Also show legacy data in Plan Usage panel with a note
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
target.appendChild(el('div', { class: 'panel-loading', style: 'color:#6b7280;' },
'quota_v2 not available (server may not have D81 yet). See legacy Quota panel below.'));
renderQuota(data.quota);
}
}
/* ─────────────── Other panel renderers (unchanged from D51) ─────────────── */
function render24h(window24h, cacheHit24h) { function render24h(window24h, cacheHit24h) {
const target = document.getElementById('panel-24h'); const target = document.getElementById('panel-24h');
target.innerHTML = ''; target.innerHTML = '';
@@ -196,7 +677,7 @@
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' }); const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
minLabel.textContent = '0'; minLabel.textContent = '0';
svg.appendChild(minLabel); svg.appendChild(minLabel);
// Date labels (first + last only at v0.3.0; mid labels deferred — added if needed by Phase 4 UX feedback) // Date labels (first + last only)
if (spendTrend30d.length > 0) { if (spendTrend30d.length > 0) {
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' }); const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
firstDate.textContent = spendTrend30d[0].date.slice(5); firstDate.textContent = spendTrend30d[0].date.slice(5);
@@ -240,6 +721,7 @@
target.appendChild(table); target.appendChild(table);
} }
/* ─────────────── Error / clear banner ─────────────── */
function showError(message) { function showError(message) {
const slot = document.getElementById('banner-slot'); const slot = document.getElementById('banner-slot');
slot.innerHTML = ''; slot.innerHTML = '';
@@ -250,6 +732,7 @@
document.getElementById('banner-slot').innerHTML = ''; document.getElementById('banner-slot').innerHTML = '';
} }
/* ─────────────── Fetch ─────────────── */
async function fetchDashboardData() { async function fetchDashboardData() {
const res = await fetch('/v0/management/dashboard-data', { const res = await fetch('/v0/management/dashboard-data', {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
@@ -266,24 +749,82 @@
return await res.json(); return await res.json();
} }
/* ─────────────── Quota-only refresh (D82 § C — 60s timer) ─────────────── */
let _lastQuotaFetchedAt = null;
async function refreshQuotaV2() {
try {
const data = await fetchDashboardData();
clearError();
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
} catch (err) {
console.warn('OLP quota refresh failed:', err.message);
}
}
function updateQuotaLastRefreshLabel() {
const span = document.getElementById('quota-last-refresh');
if (!span) return;
if (_lastQuotaFetchedAt) {
span.textContent = 'Updated ' + new Date(_lastQuotaFetchedAt).toLocaleTimeString();
}
}
/* ─────────────── 60s quota timer with visibilityState guard ─────────────── */
function startQuotaRefresh() {
if (quotaRefreshTimer !== null) return;
quotaRefreshTimer = setInterval(refreshQuotaV2, QUOTA_POLL_INTERVAL_MS);
}
function stopQuotaRefresh() {
if (quotaRefreshTimer === null) return;
clearInterval(quotaRefreshTimer);
quotaRefreshTimer = null;
}
/* ─────────────── Manual refresh button (D82 § D) ─────────────── */
(function wireRefreshButton() {
const btn = document.getElementById('quota-refresh-btn');
const icon = document.getElementById('quota-refresh-icon');
if (!btn) return;
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
icon.textContent = '⟳';
icon.classList.add('spin');
try {
await refreshQuotaV2();
} finally {
icon.classList.remove('spin');
icon.textContent = '↻';
// Re-enable after 2s spam guard
setTimeout(() => { btn.disabled = false; }, 2000);
}
});
})();
/* ─────────────── Full 30s refresh (legacy panels + meta) ─────────────── */
async function refresh() { async function refresh() {
try { try {
const data = await fetchDashboardData(); const data = await fetchDashboardData();
clearError(); clearError();
const generated = data.generated_at ? new Date(data.generated_at) : new Date(); const generated = data.generated_at ? new Date(data.generated_at) : new Date();
document.getElementById('meta').textContent = document.getElementById('meta').textContent =
'Last refresh: ' + generated.toLocaleString() + ' · next in ~30s'; 'Last refresh: ' + generated.toLocaleString() + ' · quota every 60s · other panels every 30s';
renderQuota(data.quota); // Quota section: also render on each full refresh to keep in sync
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
render24h(data.window_24h, data.cache_hit_24h); render24h(data.window_24h, data.cache_hit_24h);
renderTrend(data.spend_trend_30d); renderTrend(data.spend_trend_30d);
renderChains(data.top_fallback_chains_24h); renderChains(data.top_fallback_chains_24h);
} catch (err) { } catch (err) {
// Error banner already shown by fetchDashboardData; keep panels in
// their last-good state. Console for operator debugging.
console.warn('OLP dashboard refresh failed:', err.message); console.warn('OLP dashboard refresh failed:', err.message);
} }
} }
/* ─────────────── 30s poll (legacy panels) ─────────────── */
function startPolling() { function startPolling() {
if (pollHandle !== null) return; if (pollHandle !== null) return;
pollHandle = setInterval(refresh, POLL_INTERVAL_MS); pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
@@ -294,14 +835,24 @@
pollHandle = null; pollHandle = null;
} }
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5). /* ─────────────── visibilitychange (both timers) ─────────────── */
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') stopPolling(); if (document.visibilityState === 'hidden') {
else { refresh(); startPolling(); } stopPolling();
stopQuotaRefresh();
} else {
refresh();
startPolling();
refreshQuotaV2();
startQuotaRefresh();
}
}); });
// Initial fetch + start poll. /* ─────────────── Boot ─────────────── */
refresh().finally(startPolling); refresh().finally(() => {
startPolling();
if (document.visibilityState === 'visible') startQuotaRefresh();
});
})(); })();
</script> </script>
</body> </body>
+24
View File
@@ -9,6 +9,30 @@
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break). > **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
### Amendment 8 — 2026-05-26: Permit `quotaStatus()` direct-API access (READ-ONLY exemption) for plan-usage probes (D79D80 — Phase 5)
- **Context:** ADR 0012 (Phase 5 charter) opens 2026-05-26 to port OCP's plan-usage probe (`ocp/server.mjs:842-1109`) into `lib/providers/anthropic.mjs:quotaStatus()`. The probe calls `POST https://api.anthropic.com/v1/messages` directly with an OAuth bearer and parses `anthropic-ratelimit-unified-*` response headers. This violates the plugin contract's implicit assumption that ALL provider interaction goes through `spawn` (the binary CLI). `ALIGNMENT.md` Rule 2 (provider-CLI-as-authority) further constrains plugins to operations the provider CLI itself performs. The OCP-derived plan-usage probe satisfies neither of these — it bypasses `claude -p` and hits the public API directly. **Without an explicit exemption Amendment, D80 is unalignable.**
- **Why the exemption is sound:** The probe is strictly **READ-ONLY** (one `POST /v1/messages` with `max_tokens: 1`; the response body is discarded; only response headers are parsed) AND **subscription-scope** (the OAuth bearer is the same one Claude Code uses for `claude -p`; no extra grant is requested) AND **idempotent** (probe failure returns `null`, never throws to a caller). The "what authority backs this?" answer is: Anthropic's CLI internally makes the same `/v1/messages` call (verified 2026-05-26 by `strings` on the compiled binary — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`); the probe is mirroring an established CLI behaviour rather than introducing a new wire format. Under `ALIGNMENT.md` Rule 2, mirroring observed CLI behaviour is permitted; the Rule's intent is "don't invent wire formats Anthropic's CLI does not perform", which the probe respects.
- **Change — extend the Provider contract description:**
- `quotaStatus(authContext): { quotaInfo }` is now permitted to call provider HTTP APIs directly, subject to **all three** constraints:
1. **READ-ONLY** — the API call must not mutate provider-side state. POST is acceptable when the response is what's needed (Anthropic returns ratelimit headers on `POST /v1/messages`); the request body MUST minimise side-effects (`max_tokens: 1`, dummy `messages`).
2. **Subscription-scope reuse** — the credentials used MUST be the same auth artifact the spawn path already reads via `readAuthArtifact()`. No new OAuth grant, no new API-key registration, no separate scopes.
3. **Idempotent failure** — if the probe fails for any reason (network error, 401, 429, schema parse failure), the function returns a structured shape (`{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }` since v0.5.1; see ADR 0013 Rule 6 + ADR 0008 Amendment 2) rather than throwing. The caller (server.mjs / dashboard / `olp usage` CLI) gracefully degrades. At v0.5.0 the failure shape was the literal value `null`; v0.5.1 refined this to a structured shape so operators can distinguish auth failures from rate-limit failures from network failures from in-backoff stale-cache. The substantive idempotent-failure constraint (no throw to caller) is unchanged.
- `healthCheck()` and other contract methods are NOT extended by this Amendment. Only `quotaStatus()` may make direct API calls. A plugin that wants live data for any other contract method must continue to use `spawn` or `readAuthArtifact`.
- The probe MUST cache its result. Recommended TTL: 5 minutes (mirrors OCP `USAGE_CACHE_TTL`). Tighter TTLs (e.g. dashboard's 1-minute refresh) are served from the cached value if fresh; cache miss triggers a real probe.
- The probe MUST implement exponential backoff on refresh failures: minimum 60s, maximum 3600s (mirrors OCP `OAUTH_REFRESH_MIN_BACKOFF` / `OAUTH_REFRESH_MAX_BACKOFF`). Tight loop on failure has historically burned through Anthropic's rate limit in seconds (OCP institutional lesson 2026-04).
- The probe MUST be opt-in via `~/.olp/config.json` (`providers.<name>.quota_probe_enabled: true`; default `false`). Reasoning: a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes; the operator opts in once the credentials are configured.
- **What this Amendment does NOT permit:**
- Mutating API calls (e.g. POST/PATCH/DELETE that change provider-side state). Still forbidden.
- API calls for any contract method other than `quotaStatus()`. `spawn` / `healthCheck` / `doctorChecks` / `estimateCost` / `models` / `hints` / `name` / `displayName` / `auth` remain spawn-and-filesystem-only.
- Per-provider new auth grants. The probe uses the spawn path's existing credentials.
- Bypassing the alignment.yml blacklist. The hallucinated `/api/oauth/usage` token stays blacklisted; the probe uses `/v1/messages` (real endpoint).
- **API calls to endpoints not explicitly enumerated by the companion ADR 0013 § Rule 2.** Amendment 8 permits the *kind* of call (READ-ONLY direct API for quota probing); ADR 0013 Rule 2 enumerates *which specific endpoint* is permitted. A future reader of Amendment 8 alone should NOT infer that any READ-ONLY/idempotent endpoint is fair game — the per-endpoint containment is locked to ADR 0013. Re-opening per-endpoint scope requires an ADR 0013 amendment, not a new plugin-level interpretation of Amendment 8.
- **Backwards compatibility:** Plugins whose `quotaStatus()` still returns `null` (mistral at v0.5.0 pending D84 audit, codex permanently per Phase 5 charter) are NOT affected. No existing behaviour changes for them.
- **Authority cited at the implementation:** D80 commit cites this Amendment + `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` + `Claude Code v2.1.x § OAuth bearer + ratelimit-unified headers` + live-probe transcript from 2026-05-26 in the commit body. ALIGNMENT.md Rule 1 + Rule 5 (CI) both satisfied.
- **Tests:** Suite 38 (Phase 5 D83) covers the probe: mock HTTP server returning all 13 `anthropic-ratelimit-unified-*` headers; assert parse correctness for each; assert 5min cache; assert 60s-3600s exponential backoff on simulated 429; assert stale-cache-on-failure. At v0.5.0 stale-failure returned `{ stale: true, ... }` with `null` reserved for no-cache failures; v0.5.1 refined the return contract — `null` is now reserved STRICTLY for opt-in-off, and all failure modes (auth / rate-limit / schema-drift / network / no-creds) return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. See `test-features.mjs` Suite 38 (38u/38v/38w added for the v0.5.1 hotfix regression coverage of F1 / F2 / F3 per codex review).
- **Procedural mechanism:** Iron Rule 11 (IDR) — this Amendment, ADR 0012 (Phase 5 charter), and ADR 0013 (OAuth READ-ONLY consumption rules) land together at D79 as a single coupled commit. Reviewing them separately cannot verify consumer-producer alignment. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX) ### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too. - **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
+148
View File
@@ -2,6 +2,154 @@
- **Date:** 2026-05-25 - **Date:** 2026-05-25
- **Status:** Accepted (D48, design-only — implementation D-days D49D54 follow; Phase 3 close = v0.3.0) - **Status:** Accepted (D48, design-only — implementation D-days D49D54 follow; Phase 3 close = v0.3.0)
## Amendments
### Amendment 2 — 2026-05-27: v0.5.1 quota_v2 richer failure-mode shape (codex finding F3)
**Scope:** v0.5.1 hotfix extends `ProviderQuotaEntry` and `aggregateProviderQuota()` to surface richer failure-mode detail, addressing codex review finding F3 (operator cannot distinguish failure modes from the `unavailable` catch-all). Authority: ADR 0013 Rule 6 + codex review findings F1F3.
#### 1. Extended `ProviderQuotaEntry` shape
```js
{
provider: string,
// v0.5.1: 'unreachable' added (probe enabled, creds present, but no cache + probe failed)
status: 'live' | 'stale' | 'unreachable' | 'unavailable',
reason?: string, // only when status === 'unavailable' (no API or disabled)
schema_version: string|null,
last_fresh_at: number|null,
utilization: { '5h': number|null, '7d': number|null } | null,
reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
representative_claim: string|null,
fallback_percentage: number|null,
overage: { status: string|null, disabled_reason: string|null } | null,
raw_available: boolean,
// v0.5.1 (F3 — ADR 0013 Rule 6):
failure: { kind, message, backoff_until? } | null,
failure_kind: 'no_credentials'|'auth_failed'|'rate_limited'|'schema_drift'|'network'|'other' | null,
// Note: 'opt_in_off' is NOT in this enum — when probe is opted out, the row's status
// is 'unavailable' (not 'unreachable'); failure_kind stays null. Distinguishing
// "user opted out" from "provider has no API" requires reading config separately.
backoff_until: number | null, // epoch-ms when next probe attempt is allowed
}
```
Status semantics:
- `'unavailable'` — probe disabled (`quota_probe_enabled: false`) OR provider has no public quota API (codex, mistral). `failure`, `failure_kind`, `backoff_until` are null.
- `'live'` — probe succeeded within TTL. `failure` is null.
- `'stale'` — probe failed but stale cache exists. `failure.kind` describes why the last probe failed. `last_fresh_at` is the epoch of the last successful probe. `backoff_until` tells when the next attempt is scheduled.
- `'unreachable'` (new) — probe enabled + creds present (or missing!) but no cache available + probe failed. `failure.kind` distinguishes: `no_credentials`, `auth_failed`, `rate_limited`, `schema_drift`, `network`, `other`. `utilization` and `reset` are null (no data).
#### 2. `quotaStatus()` v0.5.1 return contract
`null` is now RESERVED for `quota_probe_enabled: false` only. All other failure paths return a structured shape:
```js
null // ONLY: opt-in off
{ probe_status: 'live', ... } // cache fresh
{ probe_status: 'stale', ..., failure: { kind, message, backoff_until } } // cache stale + backoff
{ probe_status: 'unreachable', source, schemaVersion, failure: { ... } } // no cache + failed
```
The `stale: boolean` field is retained for backwards-compat (`stale: false` on live, `stale: true` on stale). New code should use `probe_status`.
#### 3. `dashboard.html` unreachable rendering
A new CSS class `.provider-row.unreachable` (red border + light red background) and `.unreachable-reason` text style handle the new status. `failure.message` and `failure_kind` are surfaced as a short text line under the provider badge. `failure.backoff_until` renders a "backoff active: Xs remaining" note if within window.
#### 4. Authority
- ADR 0013 Rule 6 (failure transparency mandate)
- Codex review findings F1 (doctor bypass), F2 (200+empty-headers → schema_drift), F3 (failure-mode collapse)
- v0.5.1 hotfix PR
---
### Amendment 1 — 2026-05-26: D81 Phase 5 quota_v2 shape + aggregateProviderQuota()
**Scope:** D81 (Phase 5 / ADR 0012 D81) extends the audit-query layer and dashboard-data endpoint to surface the new per-provider quota shape introduced by D80 (`lib/providers/anthropic.mjs:quotaStatus()`). This amendment documents the three new interfaces.
#### 1. `models-registry.json` — new `quota_probe` top-level key
D81 adds a `quota_probe` key at the root of `models-registry.json` per ADR 0013 Rule 5 (schema_version in registry so downstream consumers can detect schema drift):
```json
{
"quota_probe": {
"schema_version": "2026-05-26",
"anthropic": {
"source": "anthropic-ratelimit-unified-headers",
"endpoint": "https://api.anthropic.com/v1/messages",
"fields_pinned": [ ...13 field names... ]
}
}
}
```
`fields_pinned` is load-bearing: if Anthropic adds/renames a header in a future CLI version, dashboard consumers comparing field-presence against this list can flag "schema drift detected" per the ADR 0013 Rule 5 drift-detection runbook. This field must be updated alongside the parser whenever a drift event occurs.
`lib/providers/anthropic.mjs` reads `quota_probe.schema_version` from the registry at call time (via `_resolveSchemaVersion()`) with the module-level `QUOTA_SCHEMA_VERSION` constant as fallback. No hard dependency on the registry — the constant is the safety net.
#### 2. `lib/audit-query.mjs` — new `aggregateProviderQuota()` export
```js
export async function aggregateProviderQuota({
providers, // Map<name, plugin> or plain object
getQuotaStatus, // optional injectable getter (name) => Promise<shape|null>
}): Promise<Array<ProviderQuotaEntry>>
```
For each provider, calls `quotaStatus()` (already cached at the plugin layer per ADR 0013 Rule 3) and normalizes to the `ProviderQuotaEntry` shape:
```js
{
provider: string,
status: 'live' | 'stale' | 'unavailable',
reason?: string, // only when status === 'unavailable'
schema_version: string|null,
last_fresh_at: number|null, // epoch-ms of last successful probe
utilization: { '5h': number|null, '7d': number|null } | null,
reset: {
'5h': number|null, '7d': number|null,
overall: number|null, overage: number|null,
} | null,
representative_claim: string|null,
fallback_percentage: number|null,
overage: { status: string|null, disabled_reason: string|null } | null,
raw_available: boolean,
}
```
Providers returning `null` from `quotaStatus()` (codex, mistral — no public quota API; or probe disabled) produce `{ status: 'unavailable', reason: 'no public quota api or probe disabled', ...null fields }`.
Providers whose `quotaStatus()` throws produce `{ status: 'unavailable', reason: <error.message>, ...null fields }`.
This function does NOT scan ndjson files; it calls live provider plugins. It is audit-query-adjacent (normalized query shape for the dashboard layer) but not audit-derived. Query model remains Lane 2 = A (in-memory, no SQLite).
#### 3. `/v0/management/dashboard-data` and `/v0/management/quota` — new `quota_v2` field
Both endpoints now return TWO quota keys:
- **`quota`** (legacy, unchanged): `Array<{ provider, ...rawQuotaStatus, available }>`. Kept for backwards compatibility with the existing `dashboard.html` (D82 will switch consumers to `quota_v2`).
- **`quota_v2`** (D81 new): `Array<ProviderQuotaEntry>` — the normalized shape from `aggregateProviderQuota()` above. This is what D82's enriched dashboard UI will consume.
Both fields are computed from the same underlying `quotaStatus()` call. The legacy `quota` key calls `quotaStatus()` independently from `quota_v2`; since the probe is cached at the plugin layer (ADR 0013 Rule 3), the double call incurs no extra API requests.
**Deprecation timeline:** the legacy `quota` key is deprecated as of D81. Target removal: v1.0.0 or when D82 completes the dashboard migration (whichever comes first). Removal requires a separate PR with a CHANGELOG entry.
#### 4. Failure handling
`aggregateProviderQuota()` never throws to the dashboard endpoint. Per-provider failures are absorbed as `{ status: 'unavailable', reason: <error> }` entries. If `aggregateProviderQuota()` itself throws (implementation bug), `handleManagementDashboardData` and `handleManagementQuota` catch the error, log `dashboard_data_quota_v2_failed` / `management_quota_v2_failed`, and return `quota_v2: []` so the rest of the payload is unaffected.
#### 5. Authority citations for this amendment
- **ADR 0012 D81** — the D-day this amendment documents.
- **ADR 0013 Rule 5** — mandate for `quota_probe.schema_version` in `models-registry.json`.
- **D80 PR #52 commit 82d2e1c** — the producer of the `quotaStatus()` shape this amendment normalizes.
- **ADR 0008 Lane 2 = A** — query model unchanged; `aggregateProviderQuota()` does not scan ndjson.
---
- **Authors:** project maintainer (with AI drafting assistance) - **Authors:** project maintainer (with AI drafting assistance)
- **Related:** - **Related:**
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints) - OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
@@ -214,6 +214,24 @@ alongside the "using server-advertised key" notice.
--- ---
## Deployment configurations (D76 amendment, 2026-05-26)
Original ADR 0011 referenced a `BIND_ADDRESS` concept that did not exist in the v0.4.0v0.4.2 codebase — the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`. D76 closes this gap by adding the `OLP_BIND` env var (default `127.0.0.1`), making the deployment-context discussion below operational rather than aspirational.
Three deployment configurations are supported:
| `OLP_BIND` value | Reachability | Anonymous-key publication |
|---|---|---|
| `127.0.0.1` (default) | Loopback only | Safe with any auth posture (no LAN exposure at all) |
| RFC1918 IP / tailnet IP / `0.0.0.0` on a trusted LAN | LAN clients only | Safe when `advertise_anonymous_key: true` — the documented "trusted-LAN" zero-config family onboarding flow |
| Public IP / `0.0.0.0` on a public-facing host | Public internet | **Incompatible with `advertise_anonymous_key: true`.** Operator MUST keep `advertise_anonymous_key: false` (default). |
The server emits a startup warn event `anonymous_key_advertised_with_lan_bind` when `OLP_BIND` is non-loopback AND `advertise_anonymous_key: true` (per the `lib/keys.mjs` + `server.mjs` checks). The warn is a **checkpoint, not a hard gate** — the server cannot tell from the bind address alone whether the operator is on a trusted LAN (RFC1918 / tailnet) or has accidentally exposed a public IP. The Re-evaluation trigger #1 below escalates to a hard gate when OLP gains a public-internet deployment mode.
`olp-connect <ip>` consumes `/health.anonymousKey` over the network — therefore requires `OLP_BIND` to include the LAN interface on the server side. Without setting `OLP_BIND=<lan-ip>` (or `0.0.0.0`), `olp-connect <ip>` will fail with `connect ECONNREFUSED` because the server only accepts loopback connections.
---
## Re-evaluation triggers ## Re-evaluation triggers
Re-open this ADR when ANY of the following fires: Re-open this ADR when ANY of the following fires:
@@ -0,0 +1,147 @@
# ADR 0012 — Phase 5 Charter: Provider Quota Probes + Dashboard Enrichment
**Status:** Accepted (Phase 5 open as of 2026-05-26)
**Date:** 2026-05-26
**D-day:** D79 (charter + ADR 0002 Amendment 8 + ADR 0013 land together as the constitutional layer of Phase 5)
## Amendments
### Amendment 1 — 2026-05-26: D84 Mistral probe NO-GO (post-D79-close spike)
The D-day table originally listed D84 as "optional, depends on D79-close 30-min Mistral docs spike". The spike completed 2026-05-26 with verdict **NO-GO** — Mistral does not expose a programmatic quota/usage endpoint **accessible to Vibe / Le Chat member / La Plateforme API keys** (the key tier OLP uses for spawning the `vibe` CLI):
- `docs.mistral.ai/api` (the public API spec) covers Chat, FIM, Embeddings, Classifiers, Files, Models, Batch, OCR, Audio, Events, Beta (Agents/Conversations/Libraries/Workflows/Observability). No usage/quota/credits/billing/limits endpoint accessible to a member API key.
- Direct probe `https://api.mistral.ai/v1/usage` returns 404.
- Mistral's "Limits and Usage" help article documents limit viewing via the `admin.mistral.ai/plateforme/limits` web console.
- No `x-ratelimit-*` response headers documented on `/v1/chat/completions`. (Third-party summaries mentioning these headers are unsourced — appears to be OpenAI-convention extrapolation.)
- OLP `lib/providers/mistral.mjs` already records this independently — DL-7 comment: "If quota/budget API surfaces in Le Chat Pro, pin the endpoint here."
**Out-of-scope but worth pinning for future revisit.** Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) DOES expose programmatic "Billing and usage queries", and the [Usage limits docs](https://docs.mistral.ai/admin/user-management-finops/usage-limits) describe usage/cost queries via that surface. The Admin API requires an **org-admin scoped API key** (separate from the member key OLP uses). For OLP's family-tier deployment posture (a maintainer's personal Le Chat Pro / La Plateforme account, not an organization's admin console), provisioning + storing an org-admin token raises the credential-scope ceiling beyond what the trusted-LAN deployment context (ADR 0011) was designed for. The NO-GO at v0.5.0 is therefore "out of scope for OLP's current deployment posture", NOT "Mistral has no programmatic surface". If the deployment posture expands to an org-admin context (e.g., a small-business multi-user deployment), this decision should be re-evaluated.
**Disposition:**
- D84 row dropped from D-day plan (struck through below).
- Mistral dashboard row in D82 UI shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates (request count, estimated cost from `estimateCost()`).
- `DL-7` in `mistral.mjs` is the documented re-entry point if Mistral ever publishes a usage endpoint.
- Phase 5 total D-day budget revised: ~5 D-days (down from ~6).
---
## Context
Phase 4 (ADR 0010) shipped OLP's operator + client UX layer — `bin/olp` operator CLI, `olp doctor` framework, `olp-connect` zero-config IDE wiring, OpenClaw `/olp` slash commands, anonymous-key deployment-context limits, SSE heartbeat. v0.4.4 is the current shipped state. Phase 4 closed every gap on the OCP-feature-parity matrix EXCEPT one: **live quota / plan-usage surfacing**.
Today `lib/providers/anthropic.mjs:445` has a stub `quotaStatus()` returning `null` (D4 placeholder). The OLP dashboard's quota panel renders "—" for all providers. OCP, in contrast, exposes a live "39% session / 30% weekly" panel — the maintainer uses this multiple times per day to decide when to throttle voluntary `claude -p` traffic away from interactive sessions. OLP cannot become an OCP successor in practice (vs. just feature-parity-on-paper) until quota surfacing works.
A pre-flight institutional-knowledge audit (2026-05-26 — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`) confirmed:
1. **The OCP probe still works today** — Anthropic returns the same `anthropic-ratelimit-unified-*` headers on every `POST /v1/messages` call. Tested live 2026-05-26 from PI231 OAuth credentials.
2. **Schema added 3 fields since OCP's 2026-04 capture**`5h-status`, `7d-status` (per-window status), `overage-reset` (only on active overage). No fields removed or renamed.
3. **Verification protocol has shifted** — Claude Code v2.1.x is now a **compiled binary** (Mach-O / ELF), not bundled JS. OCP's "grep cli.js" approach no longer applies; the replacement protocol is `strings` against the binary + periodic live probe diff.
4. **OAuth refresh path unchanged**`platform.claude.com/v1/oauth/token` + `9d1c250a-...` client_id + 60s-3600s exponential backoff.
The audit makes Phase 5 implementation low-risk: this is a port of a working OCP function, not a re-derivation. The work is mechanical + adapter-layer plumbing into OLP's plugin contract.
A parallel maintainer request (2026-05-26, with reference screenshot of claude.ai/settings/usage) asked for Claude.ai-style dashboard enrichment: per-row utilization bars, reset countdown, 1-minute auto-refresh, manual refresh button. P5-1 (probe) + P5-2 (dashboard) together unlock both: the data plus the surface. v1.x roadmap #8 is closed by P5-2.
---
## Decision
Phase 5 scope is **Provider quota probes + dashboard enrichment**. The phase opens 2026-05-26 with D79 (this charter + ADR 0002 Amendment 8 + ADR 0013 OAuth READ-ONLY consumption rules). Phase 5 close ships v0.5.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
### In scope — Phase 5 D-day plan (~6 D-days)
| D-day | Deliverable | Authority | Estimate |
|---|---|---|---|
| **D79** | This charter ADR 0012 + ADR 0002 Amendment 8 (direct-API READ-ONLY) + ADR 0013 (OAuth READ-ONLY consumption rules + schema-drift mitigation) + `package.json` `current_pre_release_identifier``0.5.0-phase5` + `CLAUDE.md release_kit.phase_rolling_mode.current_phase` → Phase 5 | This charter + audit memory | 0.5d |
| **D80** | `lib/providers/anthropic.mjs:quotaStatus()` ported from OCP `server.mjs:842-1109` — full probe with macOS-keychain auth read added (existing OLP reader only handles env + `.credentials.json`) + 5min cache + 60s-3600s refresh backoff + stale-cache-on-429 + all 13 headers parsed (including new 5h-status / 7d-status / overage-reset) | Port OCP probe + ALIGNMENT.md Rule 2 exemption per ADR 0002 Amendment 8 + audit memory | 2d |
| **D81** | `lib/audit-query.mjs` + `/v0/management/dashboard-data` extended to surface the new quota shape per provider (utilization, reset, representative-claim, fallback-percentage, overage-status). Audit-query stays in-memory scan per ADR 0008 Lane 2 = A (no SQLite). Schema migration documented in ADR 0008 § Amendment | ADR 0008 + this charter | 1d |
| **D82** | `dashboard.html` Claude.ai-style restructure — per-provider rows replace the current single Quota panel; each row: provider badge, model placeholder, utilization bar (5h + 7d), reset countdown ("Your limit will reset at HH:MM AM/PM" format from the user-shared claude.ai screenshot), status badge, representative-claim hint. 1-minute auto-refresh via `setInterval` with `document.visibilityState` guard. Manual refresh button calls `/v0/management/dashboard-data` directly | v1.x roadmap #8 + maintainer reference screenshot | 1.5d |
| **D83** | Test coverage — Suite 38 quota-probe unit tests (mock HTTP server returning the 13 headers; assert parse + cache + backoff + stale-on-429); Suite 39 dashboard rendering smoke (curl `/dashboard` after pre-seeding mock quota cache; assert HTML contains expected utilization strings); update Suite 33 doctor checks for new `anthropic.quota_probe_reachable` check | Test convention from existing suites | 1d |
| ~~D84~~ **DROPPED** | ~~Mistral `quotaStatus()` port — depends on D79-close spike~~ **NO-GO per 2026-05-26 spike (see § Amendment 1).** Mistral dashboard row in D82 shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates. `DL-7` hook point in `mistral.mjs` already marks the location for future upgrade if Mistral ever publishes a usage endpoint. Codex permanently skipped (no public API). | n/a (dropped) | 0d |
| **close** | v0.5.0 release PR — `package.json` `0.4.4 → 0.5.0`, CHANGELOG promotion, `release_kit.phase_rolling_mode.current_pre_release_identifier` advance to Phase 6 token | `CLAUDE.md release_kit overlay` | maintainer-triggered |
### Out of Phase 5 scope (with explicit triggers)
#### `X-OLP-Cost-USD` per-request response header
**Status:** Deferred to Phase 6. Was listed in ADR 0010 § Out-of-scope as "Phase 5 prerequisite". The prerequisite (provider-cost weights table) is non-trivial — needs per-(provider, model) `input_cost_per_1k_tokens` / `output_cost_per_1k_tokens` / `cache_read_discount` data sourced from each provider's published pricing page. Phase 5 already pulls in two new ADRs; adding a third data-onboarding ADR is scope creep.
**Re-open condition.** Phase 6 unless a maintainer reports a cost-attribution debugging need that warrants pulling forward.
#### `context_window_exceeded` fallback trigger (LiteLLM prior-art)
**Status:** Deferred. ADR 0010 listed this as opportunistic-in-Phase-5 unless the trigger fires sooner. The trigger has not fired in Phase 4 production traffic. Continue to defer.
#### per-(provider, model) live stats Map (replacing audit-query scan)
**Status:** Deferred. Current scan latency is ~20ms at 7-day depth. Acceptable until volume grows (>100k requests/day). Re-evaluate at Phase 6 if dashboard latency degrades.
#### Anthropic interactive-mode P0 (ADR 0009)
**Status:** Still trigger-gated on Anthropic's 2026-06-15 billing-split rollout. Phase 5 does NOT depend on P0 — the quota probe reads `anthropic-ratelimit-unified-*` headers regardless of which billing pool the spawn path consumes. If P0 succeeds Phase 7+ Phase 5's probe code remains unchanged; if P0 fails Phase 5's probe code remains unchanged. The probe is billing-pool-agnostic because the headers are subscription-pool metadata, not Agent-SDK-Credit metadata.
#### `/v1/messages` Anthropic-shape entry surface
**Status:** Still deferred per ADR 0010 § Out-of-scope. No change in Phase 5.
#### v1.x roadmap #3 / #5 / #6
**Status:** Still trigger-gated per `docs/v1x-roadmap.md`. None has fired. Continue to defer.
### Opportunistic Phase 5 micro-additions (not blocking)
Items small enough to land alongside a planned D-day without scope creep, if encountered:
- README § Dashboard screenshot update (post-P5-2 enrichment) — capture from MacBook test path per `~/.cc-rules/memory/feedback/mac_mini_never_for_testing.md`.
- `olp usage` CLI subcommand (bin/olp.mjs) surfaces the parsed quota shape in terminal form. Already partially exists (cmdUsage in bin/olp.mjs); confirm payload alignment after D80.
- Add `claude_code_oauth_client_id` config override in `~/.olp/config.json` so power users can override the hardcoded `9d1c250a-...` UUID without env-var fiddling. Mirrors compiled binary's `CLAUDE_CODE_OAUTH_CLIENT_ID` env support.
- `docs/provider-audits/anthropic.md` re-capture with current `claude --version` (v2.1.142 MacBook / v2.1.150 PI231) + binary distribution layout note.
### Exit gate — v0.5.0 close criteria
1. D79 — D84 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
2. CI green on every D-day merge commit and on the v0.5.0 release commit head. `alignment.yml` blacklist re-confirmed (no new hallucinated tokens introduced).
3. README § Quota / Plan Usage section present with screenshot of the enriched dashboard. README § Supported Providers table updated to note "quota probe: anthropic ✅, mistral ⚠️/✅ (D84 outcome), codex ❌ (no public API)".
4. ADR 0012 (this charter) + ADR 0002 Amendment 8 + ADR 0013 (OAuth READ-ONLY consumption) on disk.
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.5.0 — <date>"` with D79 — D84 entries.
6. `package.json` bumped to `0.5.0`.
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 5 → Phase 6`; `current_pre_release_identifier` advances `0.5.0-phase5 → 0.6.0-phase6`.
8. Standing autopilot grant covers D-day-by-D-day execution; v0.5.0 close PR is maintainer-triggered.
9. Live MacBook E2E verification — dashboard renders enriched panel with real quota data (probe live, not mocked).
---
## Consequences
**Positive.**
- OLP finally has the load-bearing observability OCP had — maintainer can see live "39% session / 30% weekly" and decide whether voluntary `claude -p` traffic stays or moves.
- Family members on the LAN see real reset times instead of "—", which makes the "wait 2 hours" guidance concrete vs. abstract.
- The institutional-knowledge audit captured the schema in a memory file pinned with date stamps — future ports (mistral, future provider) re-use the verification protocol without re-deriving.
- v1.x roadmap #8 (Dashboard enrichment per Claude.ai-style usage page) closes inside Phase 5 rather than waiting for a separate phase.
- Compiled-binary-distribution awareness ("no more cli.js to grep") is now codified in OLP governance; the next time Anthropic ships a major CC version, the verification protocol is already written.
**Negative.**
- ADR 0002 gains another amendment (Amendment 8). The constitution surface area for `anthropic.mjs` grows. Counter-pressure: the alternative (probe lives in `server.mjs`, like OCP) violates the plugin-architecture principle that per-provider knowledge stays in `lib/providers/`. Amendment 8 is the smaller violation.
- The probe makes one `/v1/messages` call per 5min cache miss. That's ~12 calls/hour worst case across the whole proxy (probe is per-credentials, not per-key). With `max_tokens: 1` the cost is < $0.01/day at family-scale traffic. Negligible but not zero.
- Schema-drift risk over the long horizon. Anthropic could rename or remove headers in a future version. The mitigation protocol (strings + live probe diff) is in place, but it's a manual check — needs to be invoked by the maintainer or scheduled.
- Dashboard refactor introduces a breaking-change risk for the existing dashboard.html consumers (none today, but conceptually). Bumping to v0.5.0 signals this clearly.
**Neutral.**
- Phase 5 has more ADR work than Phase 4 (3 governance docs vs. 2). The constitutional layer is deliberately heavier because direct-API access is the single biggest authority decision since the plugin contract itself.
---
## Authority + cross-references
- **Iron Rule 11 (IDR)** — Phase 5 ships across 6 D-days, each a minimum reviewable unit. The governance trio (this ADR + Amendment 8 + ADR 0013) lands at D79 as a single coupled commit (reviewing them separately cannot verify consumer-producer alignment), per ADR 0002 Amendment 7's precedent.
- **Iron Rule 12 (prior-art search)** — discharged via the audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. Memory committed prior to D80 implementation.
- **ALIGNMENT.md Rule 1 (citation)** — D80 commit must cite compiled-binary `strings` evidence per audit memory § Path A (Claude Code v2.1.x has no traditional `§ section` structure because it is a Mach-O / ELF compiled binary) plus the audit memory file path. Live-probe transcript MUST be included in the commit body.
- **ALIGNMENT.md Rule 2 (provider-CLI-as-authority)** — direct-API access bypasses the spawn-binary contract. Amendment 8 is the explicit exemption. Without Amendment 8, the D80 commit is unalignable.
- **ALIGNMENT.md Rule 5 (CI alignment.yml)** — must continue to pass. `api.anthropic.com/v1/messages` is NOT on the blacklist (correct — that's the real endpoint). The hallucinated `/api/oauth/usage` IS on the blacklist (transitive from OCP) and must remain.
- **ADR 0002 Amendment 8** — companion ADR. Direct-API access scoping; READ-ONLY constraint; opt-in via config flag (default off).
- **ADR 0013** — companion ADR. OAuth credentials shared between spawn path + probe path; refresh backoff; schema-drift mitigation protocol.
- **CLAUDE.md release_kit** — Phase boundary triggers maintainer-led version bump. D-day commits within Phase 5 stay under "Unreleased". `0.5.0-phase5` is the pre-release identifier during the phase.
@@ -0,0 +1,197 @@
# ADR 0013 — OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol
**Status:** Accepted (2026-05-26)
**Date:** 2026-05-26
**D-day:** D79 (lands alongside ADR 0012 Phase 5 charter + ADR 0002 Amendment 8 as the constitutional trio of Phase 5)
---
## Context
ADR 0002 Amendment 8 permits `quotaStatus()` to call provider HTTP APIs directly, subject to a READ-ONLY constraint. That Amendment opens the door but does not specify HOW READ-ONLY discipline is preserved across credential lifecycle events (refresh, expiry, revocation), nor how OLP detects when the upstream API schema drifts. ADR 0013 fills both gaps.
The motivating concern: a provider that ships its CLI as a **compiled native binary** (Anthropic Claude Code v2.1.x is now Mach-O on macOS, ELF on Linux) closes off the previous schema-verification path (grep `cli.js`). If OLP's probe parser silently breaks because a header was renamed, the dashboard shows stale or wrong numbers, and the maintainer's load-bearing throttling decision is based on bad data. This ADR establishes the verification protocol that survives the binary-distribution shift.
A second motivating concern: the OAuth credentials used by the probe are the SAME credentials the spawn path uses for `claude -p`. Both paths consume them; the probe must not interfere with the spawn path's ability to refresh or invalidate them. Concretely: the probe must not write to the credentials artifact, must not race the spawn path on refresh, and must not amplify a 429 into a refresh storm.
---
## Decision
### Rule 1 — Credential reuse is mandatory
The probe MUST consume the same OAuth artifact the spawn path reads via the plugin's `readAuthArtifact()`. No new OAuth grant. No alternate credential store. No environment-variable-only fallback (env var `CLAUDE_CODE_OAUTH_TOKEN` is supported as an override consistent with the spawn path, but is not the probe's primary source).
Precedence order (mirrors OCP `getOAuthCredentials` 2026-04-stable):
1. `process.env.CLAUDE_CODE_OAUTH_TOKEN` if non-empty (manual override; common in CI / dev / one-off debugging).
2. `~/.claude/.credentials.json``claudeAiOauth.accessToken` (Linux + macOS without keychain access).
3. macOS Keychain: `security find-generic-password -a "${USER}" -s "Claude Code-credentials" -w` (preferred on macOS — current `lib/providers/anthropic.mjs` only covers (1) + (2); D80 adds (3)).
Rationale: a separate OAuth grant would require the maintainer to repeat `claude setup-token` against an OLP-specific scope, doubling credential exposure and divergence risk. Reusing the spawn path's credentials guarantees the probe never has more permission than the spawn path itself.
### Rule 2 — READ-ONLY at the wire
The probe MUST issue exactly one HTTP request per cache miss. Method MAY be POST (Anthropic's ratelimit headers come back on `POST /v1/messages`; this is the only way to read them). Request body MUST minimise side effects:
- `max_tokens: 1` (cost: ~$0.000001 per probe)
- `messages: [{role: "user", content: "hi"}]` (any minimal valid payload)
- Model: cheapest available in the plan (`claude-haiku-4-5` at v0.5.0)
- Do NOT include `system` prompts, `tools[]`, `tool_choice`, large content arrays, or anything that the upstream might bill differently.
The probe MUST discard the response body. Only response headers are parsed.
The probe MUST NOT call any other HTTP path on the provider's API. No `/v1/models` enumeration, no admin endpoints, no `/v1/messages/<id>` retrievals. The only permitted endpoint is `POST /v1/messages`.
### Rule 3 — Cache TTL and refresh discipline
- Cache TTL: 5 minutes. Cache miss triggers a real probe. Cache hit returns the cached value.
- The dashboard refreshes every 1 minute; that's served from the cache between probes. A manual refresh button MAY force-clear the cache (per maintainer request 2026-05-26); ADR 0012 D82 documents the button.
- On refresh failure (token expired, 401/403/429, network error), the probe schedules an exponential backoff: minimum 60s, maximum 3600s. The cache entry is NOT invalidated during backoff; `quotaStatus()` returns the stale cache marked `{ stale: true, last_fresh_at: <epoch> }`. If no stale entry exists, returns an `unreachable` shape (v0.5.1+) rather than `null`.
- Successive successful probes reset the backoff to the minimum.
- Token refresh (`POST https://platform.claude.com/v1/oauth/token`) follows the same backoff discipline. The probe MUST NOT refresh a token more than once per backoff window. The refresh path is shared with the spawn path; both observe the same backoff.
- **All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly.** `_probeOnce()` is an internal implementation detail. Routing doctor checks through `quotaStatus()` ensures the cache+backoff discipline is enforced for every caller — including operators running `olp doctor` in a debug loop. (Clarification added v0.5.1 to address codex finding F1: the original doctor check bypassed backoff by calling `_probeOnce` directly.)
### Rule 4 — Opt-in via config
A new config field at `~/.olp/config.json` controls per-provider opt-in:
```json
{
"providers": {
"anthropic": {
"enabled": true,
"quota_probe_enabled": false
}
}
}
```
Default: `false`. The maintainer must explicitly opt in after credentials are configured. Reasoning: a fresh install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes.
`olp doctor` adds a per-provider check `<provider>.quota_probe_reachable` (only runs if `quota_probe_enabled: true`). Failed check provides a `next_action.ai_executable[]` recipe to either re-authenticate or disable the probe.
### Rule 5 — Schema-drift mitigation protocol (minimum-viable-schema gate)
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol for proactive monitoring, AND enforces a minimum-viable-schema gate at parse time:
**Minimum-viable-schema gate (v0.5.1+).** `_probeOnce()` requires at least these 4 fields present (non-null after parse) before treating a response as successful:
- `anthropic-ratelimit-unified-5h-utilization`
- `anthropic-ratelimit-unified-5h-reset`
- `anthropic-ratelimit-unified-7d-utilization`
- `anthropic-ratelimit-unified-7d-reset`
If any of these 4 is absent, `_probeOnce()` classifies the probe as a schema-drift failure (`failureKind = 'schema_drift'`), schedules backoff, and returns `null`. This means a 200 OK with zero `anthropic-ratelimit-*` headers (e.g. a server-side change, a proxy stripping headers, or a mock returning `{}`) is immediately caught as drift rather than silently cached as "live" data. The other 9 fields are tolerated as absent (overage fields are conditional; top-level status fields may be absent on edge cases). The 5h/7d core 4 are load-bearing — the dashboard's progress bars depend on them. (Gate added v0.5.1 to address codex finding F2.)
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol:
**Path A — Compiled-binary string extraction.** Run `strings` over the platform-specific binary in the claude-code distribution. Captures all hardcoded header names the binary expects:
```bash
BIN_DIR=$(npm root -g)/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-*
strings "$BIN_DIR/claude" | grep -iE "anthropic-ratelimit|/v1/(messages|oauth)|platform\.claude\.com"
```
**Path A prerequisites.** GNU or BSD `strings` (part of binutils/coreutils on Linux + macOS — always present on a normal developer machine; Windows requires WSL or `binutils-mingw`). A locally installed Claude Code v2.1.x (npm-global or volta-managed). A reviewer without `claude` installed can still run Path B but Path A is gated on having the binary on disk. A future Claude Code version that ships as a different distribution shape (e.g. Rust binary, statically linked Go) keeps the protocol valid: `strings` works on any ELF/Mach-O regardless of compile source.
**Path B — Live API probe.** Run the actual probe against `api.anthropic.com` with valid OAuth credentials. Captures what the server returns today:
```bash
curl -s -i -m 10 -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","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
| grep -iE "^anthropic-ratelimit"
```
Path A tells you what the client expects. Path B tells you what the server actually emits. The diff is the actionable schema delta.
**Required cadence.** The diff MUST be re-run at every major `claude --version` bump (v2.x → v3.x is the next trigger). The current pinned schema lives at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. After re-verification, that memory file MUST be updated (or a successor file written with a new date stamp; the old one cross-linked).
**Trigger for re-running the diff.** There is no automated detector for a major `claude --version` bump at v0.5.0. Three explicit hooks share this responsibility:
1. **Annual Alignment Audit** (`ALIGNMENT.md` § Annual Alignment Audit, every 14 May) — diff is mandatory as part of the audit checklist.
2. **`olp doctor anthropic.quota_probe_reachable` failure** — if the probe returns non-2xx for any reason other than 401/403/429/network (typical schema breaks manifest as 422 or 400), `olp doctor` surfaces a `kind: fix_provider` recipe whose first step is "re-run the Rule 5 dual-path diff".
3. **Manual maintainer attention at a major Claude Code release** — if the maintainer sees a major version bump in `claude --version`, kick off the diff before the next Phase opens. Rolling-mode discipline (CLAUDE.md release_kit) means major-version bumps usually intersect with Phase boundaries.
If the diff is missed across a major version bump, the failure mode is graceful degradation: the parser silently drops unknown headers; the dashboard shows older values (cached stale) or `null` per Rule 3; `olp doctor` surfaces the staleness.
**Required action on drift detection.** If a header is renamed or removed:
1. File a Phase-N issue tagging the maintainer.
2. Update the parser in `lib/providers/anthropic.mjs:quotaStatus()` to handle both names (graceful migration), prefer the new name.
3. Update the audit memory file with a "drift event" section recording: date, old field, new field, evidence URLs.
4. Bump the `models-registry.json` `quota_probe.schema_version` (NEW field added at D80) so downstream consumers can detect.
If a new header appears in the live response that the parser doesn't read: low-priority enhancement; add to the parser, document in the audit memory, no schema_version bump required.
### Rule 6 — Failure transparency
The probe's failure modes are visible to the operator:
- `/v0/management/dashboard-data` includes per-provider `{ quota_probe: { status: 'ok' | 'stale' | 'failed' | 'disabled', last_fresh_at, last_error?, backoff_until? } }`.
- `olp doctor` surfaces probe failure as `kind: fix_oauth` (if 401/403) or `kind: fix_provider` (if 429 with no stale cache or network error).
- The dashboard row badge shows the status; clicking a failed row shows the last error (truncated to 200 chars, no full credential traces).
### Rule 7 — Out-of-scope
This ADR does NOT govern:
- Spawn-path OAuth refresh (the spawn path's refresh logic predates this ADR and is governed by the underlying CLI). The probe shares the credential artifact but does not own the refresh.
- Anthropic-specific bearer revocation (Anthropic side). Revocation manifests as 401 to the probe, which falls into Rule 6.
- Non-Anthropic provider OAuth flows. Mistral / future providers MAY adopt this protocol via plugin-specific ADRs; ADR 0013 establishes the template.
---
## Consequences
**Positive.**
- The probe is bounded — Rule 2 caps the wire traffic, Rule 3 caps the refresh rate, Rule 4 caps activation surface.
- Schema-drift detection is procedural and reproducible — Rule 5 gives the maintainer a runbook that doesn't depend on Anthropic publishing a deprecation notice.
- Failure is visible — Rule 6 means a broken probe shows up in `olp doctor` and the dashboard, not as a silent "—" in the quota row.
- Credential reuse (Rule 1) keeps the security surface area minimal.
**Negative.**
- The `quota_probe_enabled` opt-in adds a configuration step. Mitigated by `olp doctor` surfacing the recipe when credentials are present but the probe is off.
- The schema-drift protocol is manual. Anthropic could ship a v3.x binary tomorrow and the verification only happens when the maintainer or a doctor probe failure prompts it. Counter-pressure: drift events at OCP scale (~12 months) suggest manual verification on major version bumps is sufficient.
- Stale-cache-on-failure (Rule 3) means the dashboard could show 30-minute-old data without an obvious "stale" indicator unless the UI explicitly renders the `stale: true` marker. ADR 0012 D82 requires the dashboard to surface staleness; reviewing that during P5-2 implementation.
**Neutral.**
- The protocol is portable. Future provider plugins adopting direct-API probes (mistral if its `/v1/usage` exists) can reuse the same six rules with provider-specific endpoint substitution.
---
## Alternatives considered
### A — Probe lives in `server.mjs` (OCP-style)
OCP's probe is in `server.mjs:842-1109` because OCP is single-provider and pre-plugin-architecture. Porting that pattern to OLP would violate ADR 0002 (per-provider knowledge stays in `lib/providers/`). Rejected.
### B — Spawn `claude -p --dry-run` and parse ratelimit headers
`claude -p` does not expose response headers; the CLI consumes and discards them. Even if it did, parsing CLI stdout is fragile. Rejected.
### C — Wait for Anthropic to publish a public quota API
The 2026-06-15 Agent SDK Credit billing-split announcement does not include a public quota API. Anthropic may publish one in the future; this ADR is forward-compatible (Rule 7 explicitly notes "if Anthropic publishes a public ratelimit API, this entire workaround becomes obsolete — re-evaluate"). Rejected for v0.5.0 (no ETA).
### D — Mandate token-rotation in OLP
Tempting (auditability), but OCP's experience shows token rotation breaks the spawn path more often than it improves security at family-scale deployment. The credential rotation cadence is Anthropic-side (token TTL); OLP respects whatever Claude Code does. Rejected.
---
## Authority + cross-references
- **ADR 0002 Amendment 8** — the contract-level permission. ADR 0013 is the implementation discipline for that permission.
- **ADR 0012** — Phase 5 charter that schedules D80 implementation.
- **ADR 0011** — anonymous-key deployment-context (LAN-only). Separate scope; this ADR does not amend it.
- **`~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — the live schema pin. Updated on every drift event per Rule 5.
- **`alignment.yml`** — must continue to blacklist `/api/oauth/usage` and related hallucinated tokens. Must NOT add `/v1/messages` to the blacklist (legitimate endpoint).
- **OCP `server.mjs:842-1109`** — the source-of-truth port reference for D80.
- **OCP `ALIGNMENT.md`** — the institutional precedent (2026-04-11 drift → ALIGNMENT introduction) this ADR consolidates for OLP.
+2
View File
@@ -25,6 +25,8 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. | | [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). | | [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. | | [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
| [0012](0012-phase-5-charter-quota-probes-dashboard.md) | Phase 5 Charter — Provider Quota Probes + Dashboard Enrichment | Phase 5 scope ratification (2026-05-26, Accepted). Phase 5 = port OCP's plan-usage probe to `lib/providers/anthropic.mjs:quotaStatus()` + Claude.ai-style dashboard enrichment (1-min auto-refresh + manual refresh + per-provider rows with utilization bars, reset countdowns, status badges) + optional mistral probe at D84 (codex explicitly skipped — no public API). ~6 D-days, D79 → v0.5.0. Companion to ADR 0002 Amendment 8 (direct-API READ-ONLY exemption) + ADR 0013 (OAuth READ-ONLY consumption rules). Closes v1.x roadmap #8 (dashboard enrichment). Re-confirmed schema 2026-05-26 via compiled-binary `strings` + live API probe; 3 new fields since OCP 2026-04 capture, no removals. |
| [0013](0013-oauth-read-only-consumption-and-schema-drift.md) | OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol | D79 (2026-05-26, Accepted). Implementation discipline for ADR 0002 Amendment 8. Seven rules covering: credential reuse with spawn path (no new OAuth grant); READ-ONLY at the wire (one probe per cache miss, `max_tokens:1`, headers-only parse, discard body); cache TTL 5min + 60s-3600s exponential refresh backoff + stale-cache-on-failure; opt-in via `~/.olp/config.json providers.<name>.quota_probe_enabled` (default false); schema-drift mitigation via dual-path verification (compiled-binary `strings` + live API probe diff); failure transparency through `olp doctor` + dashboard staleness markers; out-of-scope clarifications. Bound by `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` as the live schema pin. |
## When to write a new ADR ## When to write a new ADR
+72
View File
@@ -0,0 +1,72 @@
{
"phase": "Phase 5",
"exit_gate_item": "9 \u2014 Live MacBook E2E verification (dashboard renders enriched panel with real quota data)",
"captured_at_utc": "2026-05-26T07:56:36.651Z",
"host": "maintainer's MacBook (Mac client test target per project test-envs; specific IP / Tailscale node redacted per public-repo hygiene)",
"server_version": "0.4.4 (pre-v0.5.0-close; main @ commit 2b07a3b \u2014 D83)",
"olp_port": 14567,
"endpoint_tested": "/v0/management/dashboard-data",
"auth": "owner-tier OLP key (temp, revoked post-test)",
"config_opt_in": {
"providers.anthropic.quota_probe_enabled": true
},
"quota_v2_shape_proof": [
{
"provider": "anthropic",
"status": "live",
"schema_version": "2026-05-26",
"last_fresh_at": 1779782166101,
"utilization": {
"5h": 0.36,
"7d": 0.34
},
"reset": {
"5h": 1779794400,
"7d": 1780225200,
"overall": 1779794400,
"overage": null
},
"representative_claim": "five_hour",
"fallback_percentage": 0.5,
"overage": {
"status": "rejected",
"disabled_reason": "org_level_disabled_until"
},
"raw_available": true
},
{
"provider": "openai",
"status": "unavailable",
"reason": "no public quota api or probe disabled",
"schema_version": null,
"last_fresh_at": null,
"utilization": null,
"reset": null,
"representative_claim": null,
"fallback_percentage": null,
"overage": null,
"raw_available": false
}
],
"result_summary": {
"anthropic": {
"status": "live",
"schema_version": "2026-05-26",
"utilization_5h": 0.36,
"utilization_7d": 0.34,
"representative_claim": "five_hour",
"overage_status": "rejected",
"fallback_percentage": 0.5
},
"openai": {
"status": "unavailable",
"reason": "no public quota api or probe disabled"
}
},
"dashboard_screenshot": "docs/img/dashboard-v0.5.0.png",
"post_test_cleanup": [
"temp owner key (id=6yullsd-, name=e2e-d83-close-prep) revoked",
"~/.olp/config.json providers.anthropic.quota_probe_enabled flag removed (config restored to baseline)",
"test server (pid=36163, port=14567) terminated"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+20 -1
View File
@@ -8,7 +8,7 @@
3. **Where** does the work live in the tree today (file + anchor). 3. **Where** does the work live in the tree today (file + anchor).
4. **When** does it need to land (trigger: load profile, security event, governance amendment). 4. **When** does it need to land (trigger: load profile, security event, governance amendment).
**Reading order for a v1.x sprint kickoff.** As of 2026-05-25, #1 (streaming SF, D57+D58) and #2 (multi-key auth, Phase 2) are CLOSED, and #4 and #7 closed in D56. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired. **Reading order for a v1.x sprint kickoff.** As of 2026-05-26, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4 and #7 (closed in D56), and #8 (dashboard enrichment, D82 Phase 5) are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
--- ---
@@ -88,6 +88,25 @@
- **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) — ✅ **CLOSED (D82, v0.5.0)**
- **Status.** Closed at D82 (Phase 5). `dashboard.html` restructured to Claude.ai-style per-provider rows rendering `quota_v2`. Closed by PR on branch `d82-dashboard-ui-claude-ai-style`; ships with v0.5.0. 60s quota auto-refresh + manual refresh button + visibilityState guard implemented. Graceful fallback to legacy `quota` field when server runs a pre-D81 build.
- **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.
+191
View File
@@ -443,6 +443,197 @@ export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
}); });
} }
/**
* Normalize a single quotaStatus() return value from the anthropic plugin into
* the dashboard-friendly shape (D81 / ADR 0008 Amendment).
* Provider-specific: called only for 'anthropic'. Returns null if the raw
* shape is absent or malformed.
*
* v0.5.1: handles probe_status field (F3 ADR 0013 Rule 6).
* Accepts both old shape (stale: boolean) and new shape (probe_status: string).
*
* @internal used by aggregateProviderQuota()
*/
function _normalizeAnthropicQuota(raw) {
if (!raw || typeof raw !== 'object') return null;
const f = raw.fields ?? {};
// v0.5.1: probe_status field (new) takes precedence; fall back to stale bool for compat.
const probeStatus = raw.probe_status ?? (raw.stale === true ? 'stale' : 'live');
return {
schema_version: raw.schemaVersion ?? null,
last_fresh_at: (probeStatus === 'stale')
? (raw.last_fresh_at ?? null)
: (raw.probedAt ?? null),
utilization: probeStatus === 'unreachable' ? null : {
'5h': f.utilization_5h ?? null,
'7d': f.utilization_7d ?? null,
},
reset: probeStatus === 'unreachable' ? null : {
'5h': f.reset_5h ?? null,
'7d': f.reset_7d ?? null,
overall: f.reset ?? null,
overage: f.overage_reset ?? null,
},
representative_claim: f.representative_claim ?? null,
fallback_percentage: f.fallback_percentage ?? null,
overage: probeStatus === 'unreachable' ? null : {
status: f.overage_status ?? null,
disabled_reason: f.overage_disabled_reason ?? null,
},
raw_available: (typeof raw.raw === 'object' && raw.raw !== null),
// v0.5.1 (F3 — ADR 0013 Rule 6): failure detail for operator diagnostics
failure: raw.failure ?? null,
failure_kind: raw.failure?.kind ?? null,
backoff_until: raw.failure?.backoff_until ?? null,
};
}
/**
* Aggregate per-provider quota status into a normalized dashboard-friendly
* shape. This is the D81 Phase 5 extension of lib/audit-query.mjs per
* ADR 0008 Amendment (D81).
*
* For each loaded provider, calls quotaStatus() (already cached at the plugin
* layer per ADR 0013 Rule 3) and normalizes to a consistent shape. Providers
* returning null (codex, mistral) produce a { status: 'unavailable' } row.
*
* Audit-query stays in-memory scan per ADR 0008 Lane 2 = A. This function
* does NOT scan the ndjson files; it calls the live provider plugins.
*
* Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5.
*
* @param {object} args
* @param {Map<string, object>} args.providers - Map of provider name plugin object
* @param {(name: string) => Promise<object|null>} [args.getQuotaStatus] - injectable for tests;
* defaults to calling providers.get(name).quotaStatus?.()
* @returns {Promise<Array<{
* provider: string,
* status: 'live' | 'stale' | 'unavailable' | 'disabled',
* reason?: string,
* schema_version: string | null,
* last_fresh_at: number | null,
* utilization: { '5h': number|null, '7d': number|null } | null,
* reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
* representative_claim: string | null,
* fallback_percentage: number | null,
* overage: { status: string|null, disabled_reason: string|null } | null,
* raw_available: boolean,
* }>>}
*/
export async function aggregateProviderQuota({
providers,
getQuotaStatus,
} = {}) {
if (!providers) {
throw new Error('aggregateProviderQuota: providers (Map) is required');
}
// Normalize the providers argument — accept both Map and plain object.
const providerEntries = (providers instanceof Map)
? [...providers.entries()]
: Object.entries(providers);
const results = [];
for (const [name, plugin] of providerEntries) {
// Default getter: call the plugin's quotaStatus() if present.
const fetchQuota = getQuotaStatus
? () => getQuotaStatus(name)
: () => (typeof plugin?.quotaStatus === 'function' ? plugin.quotaStatus(null) : Promise.resolve(null));
let rawResult = null;
let callError = null;
try {
rawResult = await fetchQuota();
} catch (err) {
callError = err?.message ?? String(err);
}
if (callError !== null) {
// quotaStatus() threw — treat as error / unavailable.
results.push({
provider: name,
status: 'unavailable',
reason: callError,
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
});
continue;
}
if (rawResult === null || rawResult === undefined) {
// Plugin returned null: opt-in disabled (the ONLY case per v0.5.1 contract)
// or providers with no quota API at all (codex, mistral).
results.push({
provider: name,
status: 'unavailable',
reason: 'no public quota api or probe disabled',
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
failure: null,
failure_kind: null,
backoff_until: null,
});
continue;
}
// quotaStatus() returned a non-null shape — normalize.
// v0.5.1: handle probe_status field (live/stale/unreachable).
// Currently only 'anthropic' returns a structured shape; other providers
// returning structured data will work if their shape is compatible.
const probeStatus = rawResult.probe_status ?? (rawResult.stale === true ? 'stale' : 'live');
const normalized = _normalizeAnthropicQuota(rawResult);
if (normalized === null) {
// Shape was present but unrecognizable.
results.push({
provider: name,
status: 'unavailable',
reason: 'unrecognized quota shape',
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
failure: null,
failure_kind: null,
backoff_until: null,
});
continue;
}
// Map probe_status to output status:
// 'live' → 'live'
// 'stale' → 'stale'
// 'unreachable' → 'unreachable' (new in v0.5.1; dashboard renders with red border)
const outputStatus = probeStatus === 'unreachable' ? 'unreachable'
: probeStatus === 'stale' ? 'stale'
: 'live';
results.push({
provider: name,
status: outputStatus,
...normalized,
});
}
return results;
}
/** /**
* Audit-derived cache hit rate over the window. Differs from * Audit-derived cache hit rate over the window. Differs from
* `cacheStore.stats()` in server.mjs: that is the live in-process counter; * `cacheStore.stats()` in server.mjs: that is the live in-process counter;
+706 -15
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,8 @@ 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 * as http from 'node:http';
import { ProviderError } from './base.mjs'; import { ProviderError } from './base.mjs';
// ── Binary resolution ───────────────────────────────────────────────────── // ── Binary resolution ─────────────────────────────────────────────────────
@@ -118,6 +127,416 @@ 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_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 ─────────────────────────────────────── // ── 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,13 +856,152 @@ 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 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) { export async function quotaStatus(_authContext) {
return null; // 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 ─────────────────────────────────────────────────────────── // ── healthCheck ───────────────────────────────────────────────────────────
@@ -485,18 +1043,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 // 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
// next_action.human_required[]. // next_action.human_required[].
// //
// 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 +1101,136 @@ export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
}; };
}, },
}, },
{
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;
}
}
},
},
]; ];
} }
+35
View File
@@ -1,6 +1,41 @@
{ {
"version": "0.1.0-bootstrap", "version": "0.1.0-bootstrap",
"comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.", "comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.",
"quota_probe": {
"schema_version": "2026-05-26",
"comment": "D81 — ADR 0013 Rule 5 mandate: schema_version pinned in registry so downstream consumers can detect schema drift. fields_pinned is load-bearing: if Anthropic adds/renames a header, dashboard consumers comparing field-presence against this list can flag 'schema drift detected'. Last verified: 2026-05-26 via live probe against api.anthropic.com (Path B per ADR 0013 Rule 5).",
"anthropic": {
"status": "live",
"source": "anthropic-ratelimit-unified-headers",
"endpoint": "https://api.anthropic.com/v1/messages",
"fields_pinned": [
"status",
"representative_claim",
"reset",
"fallback_percentage",
"status_5h",
"utilization_5h",
"reset_5h",
"status_7d",
"utilization_7d",
"reset_7d",
"overage_status",
"overage_disabled_reason",
"overage_reset"
]
},
"openai": {
"status": "unavailable",
"reason": "no public quota endpoint exposed by the openai/codex CLI; audit-derived spend tracking only at v0.5.0",
"re_entry_point": "lib/providers/openai.mjs DL-N (when OpenAI publishes a documented quota endpoint)"
},
"mistral": {
"status": "unavailable",
"reason": "no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26 (https://docs.mistral.ai/api). Mistral Admin API exposes billing/usage but requires org-admin scope (out of scope for OLP family-tier deployment).",
"re_entry_point": "lib/providers/mistral.mjs DL-7 (when Mistral publishes a member-key-accessible usage endpoint, or when OLP scope expands to admin-key deployment)",
"admin_api_reference": "https://docs.mistral.ai/admin/security-access/admin-api"
}
},
"bootstrapCreated": 1778630400, "bootstrapCreated": 1778630400,
"bootstrapCreatedComment": "Fallback Unix timestamp for models whose precise release date is unknown. Value = 2026-05-13 (the day before the Anthropic billing-split announcement that triggered OLP). Used by handleModels() in server.mjs when a model entry does not have a model-level 'created' field. Per F12 round-5 cold-audit: OpenAI spec treats 'created' as a stable per-model attribute; synthesizing Date.now() on each request causes spurious updates for clients caching models by 'created'.", "bootstrapCreatedComment": "Fallback Unix timestamp for models whose precise release date is unknown. Value = 2026-05-13 (the day before the Anthropic billing-split announcement that triggered OLP). Used by handleModels() in server.mjs when a model entry does not have a model-level 'created' field. Per F12 round-5 cold-audit: OpenAI spec treats 'created' as a stable per-model attribute; synthesizing Date.now() on each request causes spurious updates for clients caching models by 'created'.",
"providers": { "providers": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "olp", "name": "olp",
"version": "0.4.2", "version": "0.5.1",
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.", "description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
"type": "module", "type": "module",
"main": "server.mjs", "main": "server.mjs",
+59 -3
View File
@@ -18,6 +18,14 @@
* so OLP can co-host with OCP for migration windows. ADR 0010 § * so OLP can co-host with OCP for migration windows. ADR 0010 §
* Default port. Set OLP_PORT=3456 explicitly to restore the * Default port. Set OLP_PORT=3456 explicitly to restore the
* pre-D60 default when not co-hosting with OCP.) * pre-D60 default when not co-hosting with OCP.)
* OLP_BIND listen address (default: 127.0.0.1 since D76 / v0.4.3). Set
* to 0.0.0.0 (or a specific interface IP) to accept connections
* from LAN clients (required for olp-connect <ip> to actually
* reach the server). Server emits a startup warn if BIND
* resolves to a non-loopback address AND auth.allow_anonymous
* is true (anonymous-key over LAN may be acceptable; anonymous-
* key over public internet is not see ADR 0011 § Deployment
* configurations).
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
@@ -63,11 +71,13 @@ import {
} from './lib/keys.mjs'; } from './lib/keys.mjs';
import { appendAuditEvent } from './lib/audit.mjs'; import { appendAuditEvent } from './lib/audit.mjs';
// Phase 3 / D50 — management endpoints consume the audit aggregate query layer. // Phase 3 / D50 — management endpoints consume the audit aggregate query layer.
// D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape.
import { import {
aggregateRequests as auditAggregateRequests, aggregateRequests as auditAggregateRequests,
topFallbackChains as auditTopFallbackChains, topFallbackChains as auditTopFallbackChains,
spendTrendDaily as auditSpendTrendDaily, spendTrendDaily as auditSpendTrendDaily,
cacheHitRateWindow as auditCacheHitRateWindow, cacheHitRateWindow as auditCacheHitRateWindow,
aggregateProviderQuota as auditAggregateProviderQuota,
} from './lib/audit-query.mjs'; } from './lib/audit-query.mjs';
// ── Config ──────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────
@@ -77,6 +87,14 @@ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
const VERSION = pkg.version; const VERSION = pkg.version;
const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10); const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10);
// F5 / D76: OLP_BIND env. Defaults to 127.0.0.1 (loopback only — secure
// default). Operators expose LAN by setting OLP_BIND=0.0.0.0 (or a specific
// interface). Per ADR 0011 § Deployment configurations:
// - 127.0.0.1: trusted single-machine; safe with any auth posture
// - RFC1918 / tailnet / specific LAN IP: trusted-LAN — anonymous_key OK
// - 0.0.0.0: ALL interfaces — operator MUST ensure auth posture matches the
// network reachability (e.g. no advertise_anonymous_key on public IP)
const BIND = process.env.OLP_BIND ?? '127.0.0.1';
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
// ── Logging ─────────────────────────────────────────────────────────────── // ── Logging ───────────────────────────────────────────────────────────────
@@ -253,6 +271,19 @@ if (_authConfig.advertise_anonymous_key === true) {
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.', message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
}); });
} }
// F5 / D76 (ADR 0011 Deployment configurations): publishing the anonymous
// key via /health is only safe when /health is reachable ONLY from a
// trusted network. If OLP_BIND is set to a non-loopback address AND
// advertise_anonymous_key is true, warn the operator. We can't tell from
// here whether the non-loopback bind is "trusted LAN" (RFC1918 / tailnet)
// or "public internet" — that's the operator's responsibility. The warn is
// a checkpoint, not a hard gate.
if (BIND !== '127.0.0.1' && BIND !== 'localhost' && BIND !== '::1') {
logEvent('warn', 'anonymous_key_advertised_with_lan_bind', {
message: `auth.advertise_anonymous_key=true with OLP_BIND=${BIND} — /health.anonymousKey will be reachable from any host that can connect to ${BIND}:${PORT}. Confirm this address is on a trusted LAN (RFC1918 / tailnet) — never a public IP. See ADR 0011 § Deployment configurations.`,
bind: BIND,
});
}
} }
/** @internal — test seam: inject a synthetic auth config (no file I/O). */ /** @internal — test seam: inject a synthetic auth config (no file I/O). */
@@ -2030,8 +2061,10 @@ async function handleDashboard(req, res) {
async function handleManagementDashboardData(req, res) { async function handleManagementDashboardData(req, res) {
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data', return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data',
async (_req, res2, _identity, _auditCtx) => { async (_req, res2, _identity, _auditCtx) => {
// Quota panel: collect quotaStatus from each loaded provider; null on // Quota panel (legacy): collect quotaStatus from each loaded provider; null on
// throw or null return → "unavailable" indicator. // throw or null return → "unavailable" indicator.
// DEPRECATED: kept for backwards compat with current dashboard.html (D82 will
// switch consumers to quota_v2; legacy 'quota' key removed at v1.0.0 or earlier).
const quota = []; const quota = [];
for (const [name, provider] of loadedProviders) { for (const [name, provider] of loadedProviders) {
try { try {
@@ -2042,12 +2075,27 @@ async function handleManagementDashboardData(req, res) {
} }
} }
// quota_v2 (D81 / Phase 5): normalized per-provider quota shape for enriched
// dashboard rendering. Built from aggregateProviderQuota() in lib/audit-query.mjs.
// Each entry contains: provider, status, schema_version, last_fresh_at,
// utilization, reset, representative_claim, fallback_percentage, overage, raw_available.
// Providers with null quotaStatus() return { status: 'unavailable', reason: ... }.
// Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5.
let quota_v2 = [];
try {
quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders });
} catch (err) {
// Graceful degradation: quota_v2 is optional enrichment; don't fail entire payload.
logEvent('warn', 'dashboard_data_quota_v2_failed', { error: err?.message ?? String(err) });
}
const WINDOW_24H = 24 * 60 * 60 * 1000; const WINDOW_24H = 24 * 60 * 60 * 1000;
const payload = { const payload = {
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }), window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }),
cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }), cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }),
quota, quota,
quota_v2,
spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }), spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }),
top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }), top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }),
cache_stats: cacheStore.stats(), cache_stats: cacheStore.stats(),
@@ -2064,6 +2112,7 @@ async function handleManagementDashboardData(req, res) {
async function handleManagementQuota(req, res) { async function handleManagementQuota(req, res) {
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota', return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota',
async (_req, res2, _identity, _auditCtx) => { async (_req, res2, _identity, _auditCtx) => {
// Legacy quota array (backwards compat).
const quota = []; const quota = [];
for (const [name, provider] of loadedProviders) { for (const [name, provider] of loadedProviders) {
try { try {
@@ -2073,7 +2122,14 @@ async function handleManagementQuota(req, res) {
quota.push({ provider: name, error: err?.message ?? String(err), available: null }); quota.push({ provider: name, error: err?.message ?? String(err), available: null });
} }
} }
sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota }); // quota_v2 (D81): normalized per-provider quota shape per ADR 0008 Amendment (D81).
let quota_v2 = [];
try {
quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders });
} catch (err) {
logEvent('warn', 'management_quota_v2_failed', { error: err?.message ?? String(err) });
}
sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota, quota_v2 });
}); });
} }
@@ -2258,7 +2314,7 @@ const isMain = (() => {
if (isMain) { if (isMain) {
const server = createOlpServer(); const server = createOlpServer();
server.listen(PORT, '127.0.0.1', () => { server.listen(PORT, BIND, () => {
const enabledCount = loadedProviders.size; const enabledCount = loadedProviders.size;
// D74 P3-5: banner no longer hardcodes the phase. Derives from VERSION // D74 P3-5: banner no longer hardcodes the phase. Derives from VERSION
// (which advances at every Phase close) so banner stays accurate // (which advances at every Phase close) so banner stays accurate
+1541 -3
View File
File diff suppressed because it is too large Load Diff