mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bddf2cba1e | ||
|
|
65681ed7d2 | ||
|
|
d872330c9e | ||
|
|
2b07a3bd1b | ||
|
|
a41420d0fc | ||
|
|
5288493f19 | ||
|
|
82d2e1cbea | ||
|
|
187e79321f | ||
|
|
1605400052 | ||
|
|
704d4fc8a0 | ||
|
|
6605b7b14a |
@@ -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 19–22). 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 1–5 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
@@ -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
@@ -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 F1–F3).** 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 F1–F3 (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.0–v0.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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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 Enrichment — live 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 F1–F3). 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, D35–D42).
|
- **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, D35–D42).
|
||||||
- **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 F1–F3).
|
||||||
|
- **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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 (D79–D80 — 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.
|
||||||
|
|||||||
@@ -2,6 +2,154 @@
|
|||||||
|
|
||||||
- **Date:** 2026-05-25
|
- **Date:** 2026-05-25
|
||||||
- **Status:** Accepted (D48, design-only — implementation D-days D49–D54 follow; Phase 3 close = v0.3.0)
|
- **Status:** Accepted (D48, design-only — implementation D-days D49–D54 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 F1–F3.
|
||||||
|
|
||||||
|
#### 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.0–v0.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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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: 60s–3600s 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user