mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1661f336cd | ||
|
|
5ebe3dc77c | ||
|
|
179b4707a7 | ||
|
|
b1afcde929 | ||
|
|
6d9ab1f334 | ||
|
|
68e50da68a | ||
|
|
408d5a839a | ||
|
|
251b578114 | ||
|
|
f9f2eaa059 | ||
|
|
686794e316 | ||
|
|
c0b696984f |
@@ -39,8 +39,10 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
|||||||
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
||||||
- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **✅ Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy).**
|
- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **✅ Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy).**
|
||||||
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
||||||
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).**
|
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8 + daily rotation per ADR 0008 § 5. **✅ D45 (append) + D52 (rotation) shipped. `appendAuditEvent` fires per /v1/chat/completions + /v1/models + /v0/management/* request (warn + 1 retry; no memory buffer). `_maybeRotateAudit` (sync) is called BEFORE the append when the UTC date changes; renames live → `audit-YYYY-MM-DD.ndjson`. Optional external cron tool `bin/olp-audit-rotate.mjs` for exact-at-midnight rotation.**
|
||||||
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
|
- `bin/olp-audit-rotate.mjs` — external audit rotation cron tool per ADR 0008 § 5.2. **✅ D52 — `runCli(argv, { out, err })` invocable + main-guard for direct execution. Installed via `package.json bin` so `npx olp-audit-rotate` works (also `npm run olp-audit-rotate`). Idempotent + safe alongside in-server first-append trigger.**
|
||||||
|
- `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).**
|
||||||
|
- `dashboard.html` — owner-only multi-provider dashboard (4 panels: per-provider quota / 24h request+cache+fallback / 30d spend trend SVG sparkline / top-10 fallback chains). **✅ D51 — full UI shipped at repo root per ADR 0008 § 6. Vanilla HTML + JS + fetch (no build step, no framework, no CDN). 30s page poll with `document.visibilityState` pause/resume. Served by `/dashboard` route in server.mjs owner-only_block. Cached in memory at first request via `_loadDashboardHtml`.**
|
||||||
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||||
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
|
||||||
|
|||||||
+240
-1
@@ -4,7 +4,246 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
(empty — Phase 3 entries land here once Phase 3 opens)
|
(empty — Phase 4 entries land here once Phase 4 opens)
|
||||||
|
|
||||||
|
## v0.3.1 — 2026-05-25
|
||||||
|
|
||||||
|
### Post-Phase-3 cleanup batch #1 (D56)
|
||||||
|
|
||||||
|
Patch release closing two XS v1.x-roadmap deferrals (`docs/v1x-roadmap.md` #4 + #7) that became actionable now that Phase 3 management endpoints exist. No new feature surface; pins existing behaviour into tests + finally wires the ADR-documented `activeSpawns` field on `/health`.
|
||||||
|
|
||||||
|
- **AUTH_MISSING tuple test** (v1.x roadmap #7 / D45 reviewer P3 deferral). New engine-level test in Suite D40 asserts that an `AUTH_MISSING` hop produces a `fallbackDetail` tuple with `trigger_type: 'auth_missing'` AND that the engine does NOT advance past the AUTH_MISSING hop (per ADR 0004 § Decision — `HARD_TRIGGER_CODES[AUTH_MISSING] = false`). Pre-D56 the behaviour was implicit through other engine-path tests; this commit makes it explicit so a future refactor that moves the tuple-push past the auth_missing branch fails this test directly.
|
||||||
|
- **`/health` `activeSpawns` integration** (v1.x roadmap #4 / ADR 0002 Amendment 6 forward note). `handleHealth` now surfaces `providers.status.<name>.activeSpawns` (sourced from D38 `getActiveSpawnCount(name)`). The field is computed BEFORE `healthCheck()` is awaited so it remains present even when `healthCheck()` throws (cheap in-memory counter read). New Suite 21c-extra test pins the field presence + non-negative value for every enabled provider. With no requests in flight: 0; under saturation: equals `hints.maxConcurrent`.
|
||||||
|
- **Test count:** 601 (v0.3.0) → 603 (v0.3.1). +2 D56 tests.
|
||||||
|
- **Authority:** `docs/v1x-roadmap.md` #4 + #7; ADR 0002 Amendment 6 (concurrency observability forward note); ADR 0004 § Decision + Amendment 5 (X-OLP-Fallback-Detail tuple shape).
|
||||||
|
|
||||||
|
**Patch-release classification.** Per `release_kit.phase_rolling_mode` cross-Phase discipline: D56 landed on main after v0.3.0 was tagged, so this is a hotfix-class patch — bump patch, tag, release before next push. Tag push triggers `release.yml`.
|
||||||
|
|
||||||
|
## v0.3.0 — 2026-05-25
|
||||||
|
|
||||||
|
### Phase 3 — Dashboard + audit query layer + daily audit rotation (D48 → D54)
|
||||||
|
|
||||||
|
**Overview.** v0.3.0 closes Phase 3 — the dashboard / audit aggregate query / daily rotation track that grew OLP from "audit ndjson exists but is grep-only" (v0.2.0) to a live multi-panel owner-only dashboard with aggregate queries + automatic daily file rotation. 7 D-day commits (D48 through D54) shipped between 2026-05-25 under the standing-autopilot grant. All 15 ADR 0008 § 10 acceptance criteria are implemented + tested.
|
||||||
|
|
||||||
|
**Test count: 544 (v0.2.0) → 601 (v0.3.0).** +57 tests across the Phase 3 arc.
|
||||||
|
|
||||||
|
**Phase 3 release_kit checklist**
|
||||||
|
|
||||||
|
- [x] All 7 D-day deliverables landed on main (D48 ADR + D49-D54 implementation)
|
||||||
|
- [x] CI green on every D-day merge commit + on this release commit's head
|
||||||
|
- [x] Fresh-context opus reviewer on every implementation D-day (D49/D50/D51/D52/D53) + D48 ADR draft + D54 docs polish
|
||||||
|
- [x] All 15 ADR 0008 § 10 acceptance criteria (#1–#15) covered by Suite 23/24/25/26/20h-extra-audit tests
|
||||||
|
- [x] CHANGELOG "Unreleased" promoted to "## v0.3.0 — 2026-05-25" with D48 through D54 entries
|
||||||
|
- [x] `package.json` bumped 0.2.0 → 0.3.0
|
||||||
|
- [x] `CLAUDE.md release_kit.phase_rolling_mode`: `current_phase` Phase 3 → Phase 4; `current_pre_release_identifier` `0.3.0-phase3` → `0.4.0-phase4`
|
||||||
|
- [x] README status header + Implementation Status + Phase plan reflect Phase 3 shipped
|
||||||
|
- [ ] Tag pushed (next step in this PR's lifecycle)
|
||||||
|
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push; D37 phase_rolling_mode gate will pass because Unreleased is now sentinel-only)
|
||||||
|
|
||||||
|
**ADR 0008 § 10 acceptance criteria — final ship status**
|
||||||
|
|
||||||
|
| # | Criterion | Covering tests |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `readAuditWindow` iterates events from today + N prior rotated files | Suite 23b-1, 23b-2, 23b-3 |
|
||||||
|
| 2 | `readAuditWindow` skips malformed lines without throwing + logs warn | Suite 23b-6 |
|
||||||
|
| 3 | `aggregateRequests` counts by provider / cache_status / owner_tier / path + median/p95 latency | Suite 23c-1, 23c-2, 23c-3 |
|
||||||
|
| 4 | `topFallbackChains` sort desc by count + tied-count tiebreak | Suite 23d-1, 23d-4 |
|
||||||
|
| 5 | `spendTrendDaily` sparse-fills zero-request days + UTC day boundaries | Suite 23e-1, 23e-2, 23e-3 |
|
||||||
|
| 6 | Daily rotation past UTC midnight | Suite 26a-3, 26b-1 |
|
||||||
|
| 7 | Cross-file query with mixed rotated files | Suite 26e-1 + Suite 23b-1 |
|
||||||
|
| 8 | Concurrent rotation safety (N appends → 1 rename) | Suite 26c-1 |
|
||||||
|
| 9 | `GET /dashboard` 200 to owner; 401 to non-owner | Suite 24a, 24b, 24c, 24d |
|
||||||
|
| 10 | `GET /v0/management/dashboard-data` 200 to owner with all required fields | Suite 24e |
|
||||||
|
| 11 | `GET /cache/stats` 200 to owner with live stats | Suite 24h |
|
||||||
|
| 12 | Dashboard HTML smoke (4 panel containers + 30s poll + no external resources) | Suite 25a-25f |
|
||||||
|
| 13 | Audit row on management endpoints (success + 401) | Suite 24i, 24j |
|
||||||
|
| 14 | Graceful degradation on `quotaStatus()` throw (panel surfaces null + error) | server.mjs `handleManagementDashboardData` try/catch verified by code |
|
||||||
|
| 15 | PII guard — no message-content fields in any aggregate output | Suite 23g-1, 23g-2, 23g-3 |
|
||||||
|
|
||||||
|
**Phase 3 D-day index**
|
||||||
|
|
||||||
|
- **D48** (`c0b6969`) — ADR 0008 Phase 3 design draft (Dashboard + audit query layer) + lane decisions A/A/B/A/B
|
||||||
|
- **D49** (`686794e`) — `lib/audit-query.mjs` aggregate query layer (5 functions, PII-guarded)
|
||||||
|
- **D50** (`f9f2eaa`) — `server.mjs` 4 management endpoints (owner_only_block per ADR 0008 § 8) + dashboard.html placeholder
|
||||||
|
- **D51** (`251b578`) — `dashboard.html` full multi-panel UI (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause)
|
||||||
|
- **D52** (`408d5a8`) — Daily audit rotation in `lib/audit.mjs` (synchronous trigger on first append after UTC midnight) + `bin/olp-audit-rotate.mjs` external cron tool
|
||||||
|
- **D53** (`68e50da`) — `tried_providers` schema semantic fix (D45 P2 deferral closed; ADR 0007 § 8 amendment)
|
||||||
|
- **D54** (`6d9ab1f`) — README Phase 3 polish (docs-only)
|
||||||
|
|
||||||
|
**Bonus: also resolved at D53** — D45 fresh-context opus reviewer P2 deferral (`tried_providers` semantics on `key_no_provider_access` 403). ADR 0007 § 8 amended; server.mjs sets `tried_providers = []` on the 403 path so downstream audit queries stay accurate.
|
||||||
|
|
||||||
|
**Known limitations carried beyond v0.3.0**
|
||||||
|
|
||||||
|
Phase 3 functional scope is complete. The following remain as Phase 4+ deferrals (tracked in `docs/v1x-roadmap.md` + the new Phase 4 entry below):
|
||||||
|
|
||||||
|
- **Per-key per-provider auth artifact mapping** — ADR 0007 § 12. Each OLP key independently authenticated to a different provider account.
|
||||||
|
- **Audit query rotation / retention policies** — ADR 0008 § 11. Currently unbounded; operator manages disk. A Phase 4+ amendment adds `audit_max_days` config when an operational need emerges.
|
||||||
|
- **SQLite hybrid migration** — ADR 0007 § 13. Trigger: query latency > 2s on typical owner session OR > 5 owners polling. Requires engines bump + CI matrix change as a separate prior PR.
|
||||||
|
- **Provider-cost weights for spend trend** — ADR 0008 § 11. At v0.3.0 "spend" is proxied by request count; cost integration when commercial cost-tracking lands.
|
||||||
|
- **Per-key dashboard views** — owner sees aggregate; per-key drill-down is a future amendment.
|
||||||
|
- **Key-mgmt UI on dashboard** — owner can create / revoke / edit keys from web. Out of Phase 3 scope; needs separate security review per ADR 0008 § 11.
|
||||||
|
- **Manual smoke for dashboard** — per ADR 0008 § 10 #12 the "no JS console errors in real browser" sub-claim is manual / playwright; Phase 3 acceptance shipped with server-observable checks (Suite 25); a Phase 4+ amendment may add playwright smoke if dashboard complexity grows.
|
||||||
|
|
||||||
|
### D54 — README Phase 3 polish (docs-only, no code change)
|
||||||
|
|
||||||
|
Seventh Phase 3 D-day. Documentation polish ahead of Phase 3 close (D55, maintainer-triggered). Brings README status header / Implementation Status / API Endpoints / Known limitations / Phase plan up to date with Phase 3 work shipped to main through D48-D53.
|
||||||
|
|
||||||
|
- **Status header**: `v0.2.0 shipped` → `v0.2.0 shipped; v0.3.0 in progress` + lists D48-D54 highlights.
|
||||||
|
- **Implementation status note**: Phase 3 description updated from "next milestone" to "shipped to main through D54; v0.3.0 release pending maintainer-triggered close (D55)".
|
||||||
|
- **Implementation Status table** — 4 row updates:
|
||||||
|
- `lib/audit.mjs`: 🟡 D45-only → ✅ D45 append + D52 rotation; describes both responsibilities.
|
||||||
|
- `lib/audit-query.mjs`: NEW row (D49 shipped, 5-function aggregate query API).
|
||||||
|
- `dashboard.html`: 📋 Planned (Phase 6) → ✅ Phase 3 shipped (D50 stub + D51 full UI); describes the 4 panels.
|
||||||
|
- `bin/olp-audit-rotate.mjs`: NEW row (D52 shipped, external cron tool).
|
||||||
|
- **API Endpoints table** — `/cache/stats`, `/v0/management/quota`, `/dashboard` (Phase 6 📋 Planned → Phase 3 ✅ Shipped); new `/v0/management/dashboard-data` row; `/health` row clarified to spell out owner-only-trim semantic. Removed the "placeholder — full table lands" stub since the table is now substantively complete.
|
||||||
|
- **Known limitations** — Phase 2 paragraph kept (now reads as historical Phase 2 completion note); new Phase 3 paragraph summarizing D48-D54 shipped + D55 close pending.
|
||||||
|
- **Phase plan** — Phase 3 description (was "next") → "🟡 In progress — D48 (ADR) + D49–D54 shipped to main 2026-05-25; v0.3.0 close awaits maintainer trigger." Added Phase 4+ entry covering the deferred items (per-key per-provider auth, SQLite hybrid, audit rotation/retention policies, provider-cost weights).
|
||||||
|
- **Test count:** 601 → 601 (docs-only).
|
||||||
|
- **Authority:** CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; ADR 0008 § 13 sprint shape (D54 = "E2E + AGENTS / README polish"); standing autopilot grant.
|
||||||
|
|
||||||
|
### D53 — `tried_providers` schema semantic fix (D45 P2 deferral closed)
|
||||||
|
|
||||||
|
Sixth Phase 3 D-day. Small focused fix for the D45 fresh-context opus reviewer P2 finding that was deferred: `auditCtx.tried_providers` on the `key_no_provider_access` 403 path was being stamped with the ORIGINAL chain (which was filtered out, never dispatched), distorting downstream audit queries like "which providers did key X actually call".
|
||||||
|
|
||||||
|
- **`server.mjs` 403 path fix** (around L815): `auditCtx.tried_providers = []` (was `_originalChainProviders`). The configured-but-blocked chain still appears in the human-readable error message body — the audit just doesn't claim those providers were "tried" when the server's filter dispatched zero.
|
||||||
|
- **ADR 0007 § 8 amendment**: new paragraph spelling out the `tried_providers` semantic — "the list of providers the server actually dispatched a spawn against. A provider that was configured in the chain but filtered out by `providers_enabled` gating is NOT included — the key didn't try the provider, the gate did. On the 403 path `tried_providers` is the empty array." Plus a forward note that audit log rotation moved to Phase 3 / ADR 0008 § 5.
|
||||||
|
- **Suite 20h-extra-audit (+1 test — 600 → 601):** creates a guest key with `providers_enabled: ['mistral']`; fires a request for an Anthropic-routed model; asserts 403 `key_no_provider_access`; reads the audit row from `audit.ndjson`; asserts `tried_providers === []`. This pins the D53 semantic against regression — if a future change reverts to stamping the original chain, the test fails.
|
||||||
|
- **Documentation:** CHANGELOG D53 entry; ADR 0007 § 8 amendment.
|
||||||
|
- **Test count:** 600 → 601 (+1 D53 regression test).
|
||||||
|
- **Authority:** ADR 0007 § 8 amendment (D53, 2026-05-25); D45 fresh-context opus reviewer P2 deferral note; CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D52 — Daily audit rotation (`lib/audit.mjs` extension + `bin/olp-audit-rotate.mjs`)
|
||||||
|
|
||||||
|
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to `lib/audit.mjs` per ADR 0008 § 5 + ships an external cron tool. Rotation is **synchronous** at v0.3.0 (Lane 3 = B daily rotation; synchronous design eliminates the race that an async wrapper would create between date-change-detection and the append).
|
||||||
|
|
||||||
|
- **`lib/audit.mjs` extended**:
|
||||||
|
- New `_maybeRotateAudit({ olpHome, logEvent })` (synchronous): probes the live `audit.ndjson`; if it holds events from a past UTC date, renames it to `audit-YYYY-MM-DD.ndjson`. Idempotent. If the target file already exists (cron beat the in-server check), logs warn + skips per ADR 0008 § 5.3 race safety.
|
||||||
|
- `appendAuditEvent` extended: cheap fast-path date check via module-cached `_lastSeenUtcDate`. On date change, calls `_maybeRotateAudit` synchronously BEFORE `appendFileSync` — so old-date events land in the rotated file and new-date events land in the fresh live file. No event straddles the boundary.
|
||||||
|
- Why synchronous instead of async: an async wrapper would let the sync `appendFileSync` race the not-yet-completed `renameSync`, landing today's event in the about-to-be-renamed file. Sync rotation is the only correct ordering at the append-fired-from-many-routes scale OLP runs.
|
||||||
|
- New exports: `_maybeRotateAudit` (sync), `getAuditRotateCount`, `getAuditRotateFailCount`, `__resetAuditRotateState`, `__setLastSeenUtcDateForTesting`.
|
||||||
|
- First-event-date discovery: when probing the live file's date, reads only the first ndjson line + parses its `ts`. Falls back to file mtime if events absent (corrupt/empty edge).
|
||||||
|
- **`bin/olp-audit-rotate.mjs`** (~95 lines): external cron tool per ADR 0008 § 5.2. Calls `_maybeRotateAudit` once + reports outcome. Exit codes 0 (success or no-op), 1 (bad usage), 2 (rotation failed). Installed via `package.json bin` so `npx olp-audit-rotate [--olp-home=<path>]` works. Example cron line documented in the file header.
|
||||||
|
- **Concurrent-safety semantics** (ADR 0008 § 5.3): in-process sequential appends after the first date-change detection short-circuit via the updated `_lastSeenUtcDate` cache → exactly 1 rename even under N sequential appends. Cross-process (cron + server) coexistence handled by the "target already exists → skip + warn" branch.
|
||||||
|
- **Test surface (Suite 26, +12 tests — 588 → 600):**
|
||||||
|
- 26a-1..5: `_maybeRotateAudit` (no live file / today already / yesterday→rotate / idempotent re-call / cron-race target-exists warn)
|
||||||
|
- 26b-1: `appendAuditEvent` past UTC date change triggers sync rotation + append lands in fresh live file
|
||||||
|
- 26c-1: 10 sequential `appendAuditEvent` across date change → exactly 1 rotation + all 10 events in new live file
|
||||||
|
- 26d-1..4: `bin/olp-audit-rotate.mjs` CLI (--help / no-live-file / yesterday-file-rotates / unknown-flag exit 1)
|
||||||
|
- 26e-1: rotated files queryable via `lib/audit-query.mjs` `discoverAuditFiles` + `readAuditWindow` cross-file read
|
||||||
|
- **`package.json`**: `bin.olp-audit-rotate` + `scripts.olp-audit-rotate` entries added.
|
||||||
|
- **Documentation:** AGENTS.md `lib/audit.mjs` marker promoted to ✅ (D45 append + D52 rotation both shipped); new `bin/olp-audit-rotate.mjs` entry.
|
||||||
|
- **Test count:** 588 → 600 (+12 D52 tests in Suite 26).
|
||||||
|
- **Authority:** ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger), § 5.2 (external cron alternative), § 5.3 (concurrent-rotation safety + cron-coexistence semantics), § 5.4 (renamed-file query path consumed by D49 lib/audit-query.mjs); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D51 — `dashboard.html` full multi-panel UI (Phase 3)
|
||||||
|
|
||||||
|
Fourth Phase 3 D-day. Replaces the D50 `dashboard.html` placeholder with the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no build step, no framework, no CDN (Lane 1 = A). 30s page poll with `document.visibilityState` pause/resume (Lane 4 = A).
|
||||||
|
|
||||||
|
- **4 panels rendered from `/v0/management/dashboard-data`** (the single backing endpoint, per Lane 2 in-memory query model):
|
||||||
|
- **Panel 1 — Per-provider quota**: table of `{ Provider | Available | Status }`; surfaces `null` available as "n/a" + capturing per-provider `provider.quotaStatus()` errors as a red status pill (graceful degradation per ADR § 9).
|
||||||
|
- **Panel 2 — Last 24h: request count + cache hit + fallback rate**: per-provider row of `{ Requests | Cache hit % | Fallback rate % }`. Cache hit sourced from `cache_hit_24h.by_provider[p].hit_rate`; fallback rate computed from `window_24h.by_provider[p].fallback_count / count`.
|
||||||
|
- **Panel 3 — Request count last 30 days (SVG sparkline)**: vanilla SVG bar chart with `<title>` tooltips showing per-day per-provider breakdown. Y-axis: requests per day (scaled to max); X-axis: 30 daily buckets (UTC). Each bar `<title>` includes the date + total count + provider breakdown.
|
||||||
|
- **Panel 4 — Top fallback chains (last 24h)**: numbered table of `{ # | Chain | Count | First seen | Last seen }` with chain arrows rendered in monospace (`anthropic → openai`).
|
||||||
|
- **30s poll + visibilitychange pause** (ADR 0008 § 6.5):
|
||||||
|
- `setInterval(refresh, 30000)` after the initial fetch.
|
||||||
|
- `document.addEventListener('visibilitychange', ...)` → `stopPolling()` on hidden / `refresh() + startPolling()` on visible.
|
||||||
|
- Per ADR § 6.5 this prevents 2880 background polls/day per owner when the dashboard tab is in the background.
|
||||||
|
- **Error handling**:
|
||||||
|
- 401 from `/v0/management/dashboard-data` → in-page error banner explains owner-tier requirement + suggests SSH-tunnel + header-injection workaround (browsers can't natively send `Authorization: Bearer` without a proxy/extension).
|
||||||
|
- Other HTTP errors → generic "HTTP <code>" banner; console.warn for operator debugging.
|
||||||
|
- Per-panel "Loading…" / "No requests in window." / "No fallback chains triggered" empty states.
|
||||||
|
- **DOM helpers**: small `el(tag, attrs, ...children)` + `svgEl(tag, attrs)` factories — no framework, ~10 lines each. Sparkline uses native `<title>` for tooltips (no JS hover handlers).
|
||||||
|
- **Critical correctness invariants** (per ADR 0008 § 6 + Lane 1 = A):
|
||||||
|
- No `<script src>` — entire JS inline in `<script>` tag (Suite 25d asserts).
|
||||||
|
- No `<link rel="stylesheet" href=>` — all CSS in `<style>` tag (Suite 25d asserts).
|
||||||
|
- Only one backing endpoint hit: `/v0/management/dashboard-data` (Suite 25e asserts). All 4 panels consume slices of its response.
|
||||||
|
- 401 path keeps panels in last-good state rather than clearing them; operator sees the error banner + can debug.
|
||||||
|
- **Test surface (Suite 25, +6 tests — 582 → 588):**
|
||||||
|
- 25a: owner /dashboard response contains all 4 panel container IDs (`panel-quota`, `panel-24h`, `panel-trend`, `panel-chains`).
|
||||||
|
- 25b: dashboard JS declares `POLL_INTERVAL_MS = 30000` + uses `setInterval` + `clearInterval`.
|
||||||
|
- 25c: visibilitychange listener wired + checks `document.visibilityState === 'hidden'`.
|
||||||
|
- 25d: NO external `<script src>` and NO external stylesheet `<link href>` — pinning Lane 1 = A.
|
||||||
|
- 25e: dashboard JS fetches `/v0/management/dashboard-data` (the single consolidated D50 endpoint).
|
||||||
|
- 25f: 401 in-page error banner mentions owner-tier so a maintainer who lands on a 401 knows the route forward.
|
||||||
|
- **Manual smoke (ADR 0008 § 10 #12 manual acceptance)**: the dashboard renders without console errors in a real browser when served by a running OLP instance + owner-tier Bearer token injected via SSH-tunnel + header-injection extension. Not automated at Phase 3 (Lane 4 = A poll model doesn't need playwright; Phase 4+ may add a playwright smoke if dashboard complexity grows).
|
||||||
|
- **Documentation:** AGENTS.md `dashboard.html` marker promoted from 🟡 D50 placeholder to ✅ D51 full UI.
|
||||||
|
- **Test count:** 582 → 588 (+6 D51 tests in Suite 25).
|
||||||
|
- **Authority:** ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll + visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A (30s poll) + Lane 5 = B (full 4-panel scope); ADR § 9 (graceful degradation surfaced in Panel 1); ADR § 10 criterion #12 (HTML smoke); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D50 — `server.mjs` management endpoints (Phase 3 dashboard wire-up)
|
||||||
|
|
||||||
|
Third Phase 3 D-day. Wires the D49 `lib/audit-query.mjs` aggregate query layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8. Ships a placeholder `dashboard.html` at repo root (D51 lands the full multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit + touchLastUsed pattern.
|
||||||
|
|
||||||
|
- **4 new endpoints** (all owner_only_block per ADR 0008 § 8 — anonymous + guest + missing-key all → 401):
|
||||||
|
- `GET /dashboard` — serves `dashboard.html` (Content-Type text/html; charset=utf-8). D50 stub explains the state + lists backing endpoints; D51 replaces with full UI.
|
||||||
|
- `GET /v0/management/dashboard-data` — full aggregate per ADR 0008 § 7.2: `{ generated_at, window_24h (auditAggregateRequests), cache_hit_24h (auditCacheHitRateWindow), quota (per-provider provider.quotaStatus + error capture), spend_trend_30d (auditSpendTrendDaily — exactly 30 entries), top_fallback_chains_24h (auditTopFallbackChains limit 10), cache_stats (live cacheStore.stats()) }`.
|
||||||
|
- `GET /v0/management/quota` — quota subset only (subset of dashboard-data; useful for scripted monitoring).
|
||||||
|
- `GET /cache/stats` — live in-memory `cacheStore.stats()` shape (`{ hits, misses, size, inflightCount }` + `generated_at` wrapper).
|
||||||
|
- **`_runOwnerOnlyManagementEndpoint(req, res, method, path, inner)` helper** factors the common auth + audit ctx + owner-block + res.on('finish') wire. inner is async (req, res, olpIdentity, auditCtx) → returns void. Eliminates 4× boilerplate.
|
||||||
|
- **`owner_only_block` mode** (ADR 0008 § 8): authenticate → if not owner → 401 `owner_required`. Distinct from `owner_only_trim` (Phase 2 /health pattern). Anonymous identity (when `allow_anonymous: true`) reaches the handler and is 401'd by the owner check — verified by Suite 24c.
|
||||||
|
- **Provider quotaStatus error capture**: dashboard-data + quota endpoints catch per-provider throws and surface `{ provider, error, available: null }` so one bad provider doesn't fail the whole panel (ADR 0008 § 9 graceful degradation).
|
||||||
|
- **`dashboard.html` placeholder** (~50 lines at repo root): explains the D50 state, lists backing endpoints with curl example. Cached in memory at first /dashboard request (`_loadDashboardHtml` with module-scope `_dashboardHtmlCache`); falls back to an in-memory stub if the file is missing (e.g., test imports from non-repo cwd).
|
||||||
|
- **Audit on management endpoints** (ADR 0008 § 7.5): every management request appends an audit row including 401 paths (verified by Suite 24j). Touch wire skips anonymous + env-owner identities (matches Phase 2 pattern).
|
||||||
|
- **Router**: 4 new GET branches added between /v1/chat/completions and the 404 fallback.
|
||||||
|
- **Test surface (Suite 24, +11 tests — 571 → 582):**
|
||||||
|
- 24a-d: /dashboard owner_only_block (owner 200 / guest 401 / anonymous-with-allow_anonymous=true 401 / no-auth-with-allow_anonymous=false 401)
|
||||||
|
- 24e: dashboard-data owner → 200 JSON with all required ADR § 7.2 fields (asserts `spend_trend_30d.length === 30`)
|
||||||
|
- 24f: dashboard-data guest → 401 owner_required
|
||||||
|
- 24g: quota owner → 200 JSON with quota array
|
||||||
|
- 24h: cache/stats owner → 200 JSON with `{ hits, misses, size, inflightCount, generated_at }`
|
||||||
|
- 24h-401: cache/stats guest → 401
|
||||||
|
- 24i: successful dashboard-data appends audit row with `status_code: 200` + `key_id` + `path: '/v0/management/dashboard-data'`
|
||||||
|
- 24j: 401 (guest blocked) dashboard-data appends audit row with `error_code: 'owner_required'` + `owner_tier: 'guest'`
|
||||||
|
- **Documentation:** AGENTS.md `lib/audit-query.mjs` D49 marker note added + new `dashboard.html` entry (D50 placeholder).
|
||||||
|
- **Test count:** 571 → 582 (+11 D50 tests in Suite 24).
|
||||||
|
- **Authority:** ADR 0008 § 7 (endpoints) + § 8 (owner_only_block mode) + § 9 (graceful degradation) + § 7.5 (audit on management endpoints); ADR 0007 § 7 (auth model reused); ADR 0002 § Provider contract (quotaStatus); ADR 0005 (cacheStore.stats); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D49 — `lib/audit-query.mjs` audit aggregate query layer (Phase 3)
|
||||||
|
|
||||||
|
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory ndjson scan; cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). No server.mjs integration in this D-day (D50 wires the consuming endpoints).
|
||||||
|
|
||||||
|
- **New file `lib/audit-query.mjs`** (~370 lines): 5 public API functions per ADR 0008 § 4.1:
|
||||||
|
- `discoverAuditFiles({ olpHome })` — filesystem scan; returns `Map<date|'live', path>`.
|
||||||
|
- `readAuditWindow({ startMs, endMs, olpHome, logEvent })` — generator over events in half-open window [startMs, endMs). Walks rotated date files + live file. Skips malformed lines + logs warn.
|
||||||
|
- `aggregateRequests({ windowMs, olpHome })` — counts + status buckets + by_provider + by_owner_tier + by_path + median/p95 latency over rolling window.
|
||||||
|
- `topFallbackChains({ windowMs, limit, olpHome })` — top-N chains by trigger count from events with `fallback_hops > 0`. Tied-count tiebreak: ascending first_seen.
|
||||||
|
- `spendTrendDaily({ days, olpHome })` — daily series ending today with sparse-fill for zero-request days. Per-day request_count + median latency + by_provider breakdown.
|
||||||
|
- `cacheHitRateWindow({ windowMs, olpHome })` — audit-derived cache hit rate (bypass excluded from denominator); per-provider + overall.
|
||||||
|
- **PII discipline** (ADR 0008 § 4.3): every aggregate function relays only schema fields; never message content. Suite 23g actively asserts the absence of `content`/`message`/`messages`/`prompt`/`response`/`body` keys in every aggregate output.
|
||||||
|
- **Cross-file walk semantics** (ADR 0008 § 4.2): half-open window [startMs, endMs); date-range computed once from window bounds; each rotated date file checked; live `audit.ndjson` always checked (it covers today regardless of whether the window endpoint is past midnight).
|
||||||
|
- **`spendTrendDaily` calendar-date semantics**: `days: N` returns "last N calendar UTC dates ending today" — NOT "events within a rolling N×86400-ms window" (which would span N+1 distinct UTC dates and produce off-by-one buckets at non-midnight call times). Computed via `for (let i = days-1; i >= 0; i--) dates.push(_utcDateFromMs(now - i*86400*1000));`.
|
||||||
|
- **`cacheHitRateWindow` denominator**: hit_rate = hit / (hit + miss). Bypass is intentional non-cacheable (Anthropic cache_control marker), NOT a cache miss; excluding it from the denominator gives a clean cache-effectiveness signal.
|
||||||
|
- **Test surface (Suite 23, +27 tests — 544 → 571):**
|
||||||
|
- 23a-1..4: `discoverAuditFiles` (empty dir / live only / live+rotated / non-audit files ignored)
|
||||||
|
- 23b-1..6: `readAuditWindow` (all-coverage / single-day / half-open exclusivity / empty window / missing files / malformed-skip with warn)
|
||||||
|
- 23c-1..4: `aggregateRequests` (counts + status buckets + by_provider; by_owner_tier; median+p95 latency over realistic distribution; invalid windowMs rejection)
|
||||||
|
- 23d-1..4: `topFallbackChains` (sort desc by count; limit truncation; fallback_hops=0 excluded; first_seen/last_seen carried)
|
||||||
|
- 23e-1..3: `spendTrendDaily` (N-day range correctness; populated day breakdown; empty day sparse-fill)
|
||||||
|
- 23f-1..3: `cacheHitRateWindow` (overall + per-provider hit_rate; bypass not in denominator; cache_status=null events excluded)
|
||||||
|
- 23g-1..3: PII guard for `aggregateRequests` / `spendTrendDaily` / `topFallbackChains` + `cacheHitRateWindow` — every output JSON-stringified + scanned for forbidden PII keys
|
||||||
|
- **Documentation:** AGENTS.md `lib/audit-query.mjs` new entry; `lib/audit.mjs` note added that D52 extends with daily rotation.
|
||||||
|
- **Test count:** 544 → 571 (+27 D49 tests).
|
||||||
|
- **Authority:** ADR 0008 § 4 (query API surface) + § 5 (rotation file naming pattern) + § 3 (storage layout); ADR 0007 § 8 (audit ndjson event schema — input data); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D48 — ADR 0008 Phase 3 design draft (Dashboard + audit query layer)
|
||||||
|
|
||||||
|
First Phase 3 D-day. Design-only. Ratifies the storage / query model / rotation / dashboard / refresh / scope decisions ahead of D49+ implementation D-days. Opens ADR 0007 § 12 deferral for Dashboard + audit query layer + rotation.
|
||||||
|
|
||||||
|
- **New file `docs/adr/0008-dashboard-and-audit-query.md`** (~390 lines): 13 sections + Consequences + Authority citations. Decisions per maintainer-pinned lanes:
|
||||||
|
- Lane 1 (tech stack): static HTML + vanilla JS + fetch (no build step; matches OLP "no bundler" ethos)
|
||||||
|
- Lane 2 (query model): in-memory scan of audit ndjson per request (defers SQLite hybrid per ADR 0007 § 13)
|
||||||
|
- Lane 3 (rotation): daily rotation, `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight + optional `bin/olp-audit-rotate.mjs` external cron
|
||||||
|
- Lane 4 (refresh): 30s page poll (no SSE infra at v0.3.0)
|
||||||
|
- Lane 5 (dashboard scope): full per spec § 4.6 — 4 panels (quota / per-provider 24h counts / 30d spend trend / top fallback chains)
|
||||||
|
- **`docs/adr/README.md` index**: added ADR 0008 row with one-paragraph summary.
|
||||||
|
- **CHANGELOG.md** Unreleased: this entry.
|
||||||
|
- **Phase 3 sprint shape:** D49 `lib/audit-query.mjs` + Suite 23 → D50 `/v0/management/*` endpoints + Suite 24 → D51 `dashboard.html` → D52 daily audit rotation + Suite 25 → D53 `tried_providers` schema fix (D45 P2 deferral) → D54 E2E + docs → D55 Phase 3 close → v0.3.0 (maintainer-triggered).
|
||||||
|
- **Fold-in (fresh-context opus reviewer findings — 1 P2 + 2 P3, all ADR-text polish):**
|
||||||
|
- **P2 § 8 + § 10 #9 gating-mode wording** — original § 8 implied a new "block non-owner identities" behaviour without naming it; § 10 #9 tested only the universal `allow_anonymous: false` 401 case. Fix: § 8 now formalizes two gating modes — `owner_only_trim` (Phase 2 /health pattern) vs `owner_only_block` (new Phase 3 management-endpoints pattern) — and explains the management endpoints are `owner_only_block` because the entire payload is sensitive. § 10 #9 now covers both 401 paths (with `allow_anonymous: true` + no header → anonymous identity → still 401 because management endpoints are `owner_only_block`; AND with `allow_anonymous: false` + no header → 401 at the authenticate middleware itself).
|
||||||
|
- **P3 `/cache/stats` citation accuracy** — original § 7.4 + Authority block cited "ADR 0005 § Cache stats" which is not a real section. Corrected: planning authority is OLP v0.1 spec § 4.6; ADR 0005 references the endpoint in `Consequences/Mitigations` (~line 279) for the per-`(provider, model)` cache-hit-rate breakdown surface.
|
||||||
|
- **P3 `cacheStore.stats()` shape gap** — § 7.4 now explicitly acknowledges the current shape (`{ hits, misses, size, inflightCount }` global aggregate) lacks the per-`(provider, model)` breakdown spec § 4.6 implies; Phase 3 Panel 2 sources per-provider counts from `aggregateRequests` (audit-side) instead. If a future panel needs the breakdown, D50 amends the store shape + an ADR 0005 amendment fires at that time. Phase 3 acceptance criteria do not require the breakdown.
|
||||||
|
- **Test count:** 544 → 544 (design-only, no test change).
|
||||||
|
- **Authority:** ADR 0007 § 12 (opens deferral) + § 13 (rejects SQLite at Phase 3 per Node baseline); v0.1 spec § 4.6 / § 4.7 (Dashboard + observability endpoints planning authority); OCP `dashboard.html` (prior art); CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for design ADR; Phase 3 kickoff via maintainer "go" 2026-05-25 + standing-autopilot grant; PR #25 fresh-context opus reviewer findings (3 polish items).
|
||||||
|
|
||||||
## v0.2.0 — 2026-05-25
|
## v0.2.0 — 2026-05-25
|
||||||
|
|
||||||
|
|||||||
@@ -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 3
|
current_phase: Phase 4
|
||||||
current_pre_release_identifier: "0.3.0-phase3"
|
current_pre_release_identifier: "0.4.0-phase4"
|
||||||
phase_close_trigger: explicit maintainer action (not automated)
|
phase_close_trigger: explicit maintainer action (not automated)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
|
||||||
|
|
||||||
> **Status:** v0.2.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI. Phase 3 (Dashboard + audit query layer) is the next milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
> **Status:** v0.3.0 shipped (2026-05-25) — 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 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -94,16 +94,15 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
_placeholder — full table lands as each endpoint lands._
|
|
||||||
|
|
||||||
| Endpoint | Method | Phase | Status | Description |
|
| Endpoint | Method | Phase | Status | Description |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `/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 (owner-only). |
|
| `/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. |
|
||||||
| `/cache/stats` | GET | 5 | 📋 Planned | Cache hit rate, by-provider breakdown. |
|
| `/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. |
|
||||||
| `/v0/management/quota` | GET | 6 | 📋 Planned | Per-provider quota / credit pool status (best-effort). |
|
| `/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. |
|
||||||
| `/dashboard` | GET | 6 | 📋 Planned | Owner-only dashboard (localhost-bound by default). |
|
| `/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. |
|
||||||
|
| `/cache/stats` | GET | 3 | ✅ Shipped (D50) | Live in-memory `cacheStore.stats()` (`{ hits, misses, size, inflightCount }` + `generated_at`). Owner-only_block. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,7 +166,7 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
|
|||||||
|
|
||||||
## Implementation status (as of 2026-05-25, post-v0.2.0)
|
## Implementation status (as of 2026-05-25, post-v0.2.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 (Dashboard + audit query layer) is the next milestone. 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 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||||
|
|
||||||
| File / artifact | Status | Notes |
|
| File / artifact | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -184,8 +183,10 @@ Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phas
|
|||||||
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
|
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
|
||||||
| `lib/keys.mjs` | ✅ Phase 2 shipped (D44 + D45 + D46) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` / `owner_only_endpoints` / `fallback_detail_header_policy`. Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response, trims `/health` payload for non-owner, gates `X-OLP-Fallback-Detail` emission by policy. |
|
| `lib/keys.mjs` | ✅ Phase 2 shipped (D44 + D45 + D46) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` / `owner_only_endpoints` / `fallback_detail_header_policy`. Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response, trims `/health` payload for non-owner, gates `X-OLP-Fallback-Detail` emission by policy. |
|
||||||
| `bin/olp-keys.mjs` | ✅ Phase 2 shipped (D47) | Keygen CLI per ADR 0007 § 9.1. `npx olp-keys keygen --owner` generates an owner key + prints plaintext token once; `npx olp-keys list` enumerates keys (token_hash redacted); `npx olp-keys revoke --id=X` marks a key revoked. `--olp-home=<path>` overrides `~/.olp/`. |
|
| `bin/olp-keys.mjs` | ✅ Phase 2 shipped (D47) | Keygen CLI per ADR 0007 § 9.1. `npx olp-keys keygen --owner` generates an owner key + prints plaintext token once; `npx olp-keys list` enumerates keys (token_hash redacted); `npx olp-keys revoke --id=X` marks a key revoked. `--olp-home=<path>` overrides `~/.olp/`. |
|
||||||
| `lib/audit.mjs` | 🟡 Phase 2 — shipped at D45 | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded (no message / response content). `OLP_HOME` env override supported for test/operator isolation. |
|
| `lib/audit.mjs` | ✅ Phase 2 + 3 (D45 append + D52 rotation) | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` + `/v0/management/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded. D52 adds synchronous daily rotation per ADR 0008 § 5 — first append after UTC midnight renames live → `audit-YYYY-MM-DD.ndjson`. |
|
||||||
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
|
| `lib/audit-query.mjs` | ✅ Phase 3 shipped (D49) | Audit ndjson aggregate query layer per ADR 0008 § 4. 5 functions: `discoverAuditFiles`, `readAuditWindow`, `aggregateRequests`, `topFallbackChains`, `spendTrendDaily`, `cacheHitRateWindow`. In-memory cross-file scan; PII guard at output. Consumed by `/v0/management/dashboard-data`. |
|
||||||
|
| `dashboard.html` | ✅ Phase 3 shipped (D50 stub + D51 full UI) | Owner-only multi-provider dashboard per ADR 0008 § 6. 4 panels (quota / 24h request stats / 30d SVG sparkline / top fallback chains). Vanilla HTML+JS+fetch (no build step). 30s page poll with `document.visibilityState` pause. Served by `/dashboard` route owner-only_block. |
|
||||||
|
| `bin/olp-audit-rotate.mjs` | ✅ Phase 3 shipped (D52) | External audit rotation cron tool per ADR 0008 § 5.2. `npx olp-audit-rotate [--olp-home=<path>]`. Idempotent + safe alongside the in-server first-append trigger. |
|
||||||
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
|
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
|
||||||
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
||||||
| `docs/alignment-audits/` | 📋 Planned | Output directory for annual alignment audits (first audit: 2027-05-14) |
|
| `docs/alignment-audits/` | 📋 Planned | Output directory for annual alignment audits (first audit: 2027-05-14) |
|
||||||
@@ -198,7 +199,9 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
|||||||
|
|
||||||
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
||||||
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
||||||
- **Multi-key auth + owner gating + keygen CLI all shipped (D44 + D45 + D46 + D47); Phase 2 close pending.** `lib/keys.mjs` core shipped at D44; `server.mjs` invokes `validateKey` per request, filters the chain by `providers_enabled`, fires `touchLastUsed` post-response, and appends an audit row to `~/.olp/logs/audit.ndjson` for each `/v1/*` request (D45). At D46: `/health` payload trimmed for non-owner (returns `{ ok, version }` when `owner_only_endpoints` includes `/health`); `X-OLP-Fallback-Detail` emission gated by `fallback_detail_header_policy` (`owner_only` default suppresses for non-owner; `'all'` opts back into v0.1.1 behaviour; `'none'` suppresses entirely). At D47: keygen CLI shipped at `bin/olp-keys.mjs` — `npx olp-keys keygen --owner` produces an owner key (plaintext printed once); `npx olp-keys list` / `revoke` for lifecycle management. Phase 2 functional scope is complete; close to v0.2.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
- **Multi-key auth + owner gating + keygen CLI shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
|
||||||
|
|
||||||
|
- **Phase 3 (Dashboard + audit query layer + rotation) shipped to main (D48-D54); v0.3.0 release pending.** `docs/adr/0008-dashboard-and-audit-query.md` ratified at D48. `lib/audit-query.mjs` (D49) implements the 5-function aggregate query API (in-memory ndjson scan, PII-guarded). 4 new owner-only_block endpoints at D50 (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`). `dashboard.html` full multi-panel UI at D51 (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause). Daily audit rotation at D52 (synchronous on first append after UTC midnight; `audit-YYYY-MM-DD.ndjson` naming) + optional `bin/olp-audit-rotate.mjs` cron tool. `tried_providers` schema semantic fix at D53 (D45 P2 deferral). Phase 3 close to v0.3.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`.
|
||||||
|
|
||||||
**Bootstrap workflow (D47):** for first-run / production setup:
|
**Bootstrap workflow (D47):** for first-run / production setup:
|
||||||
|
|
||||||
@@ -249,8 +252,9 @@ The original v0.1 spec (in `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the
|
|||||||
- **Phase 0** — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. ✅ Shipped (2026-05-23).
|
- **Phase 0** — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. ✅ Shipped (2026-05-23).
|
||||||
- **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 (`dashboard.html`) + audit query layer (deferred from Phase 2). Owner-only multi-provider quota panels, fallback rate, cache hit rate; localhost-bound by default. **(next)**.
|
- **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+** — v1.x roadmap items as triggered. 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 (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 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.
|
||||||
|
|
||||||
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations. Phase 2 kickoff handoff: `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`.
|
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations. Phase 2 kickoff handoff: `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`.
|
||||||
|
|||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* bin/olp-audit-rotate.mjs — External audit rotation cron tool (Phase 3 / D52)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0008 § 5.2 (external cron alternative to in-server first-
|
||||||
|
* append-after-UTC-midnight trigger).
|
||||||
|
*
|
||||||
|
* Use case: operators who want exact-at-UTC-midnight rotation rather than
|
||||||
|
* "first request after midnight". Invoke from a host cron / launchd job.
|
||||||
|
*
|
||||||
|
* Idempotent + safe to run alongside the in-server check (both detect the
|
||||||
|
* date condition; whichever fires first does the rename; the other no-ops).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* olp-audit-rotate [--olp-home=<path>]
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 = success (rotation performed OR no rotation needed)
|
||||||
|
* 1 = bad usage (unknown flag)
|
||||||
|
* 2 = rotation attempted + failed (e.g., EACCES)
|
||||||
|
*
|
||||||
|
* Example cron line (UTC midnight):
|
||||||
|
* 1 0 * * * /usr/local/bin/node /path/to/bin/olp-audit-rotate.mjs >> /var/log/olp-audit-rotate.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { _maybeRotateAudit } from '../lib/audit.mjs';
|
||||||
|
|
||||||
|
function parseArgv(argv) {
|
||||||
|
const flags = {};
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg.startsWith('--')) {
|
||||||
|
const eq = arg.indexOf('=');
|
||||||
|
if (eq > 0) flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
||||||
|
else flags[arg.slice(2)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `OLP audit rotation cron tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
olp-audit-rotate [--olp-home=<path>]
|
||||||
|
|
||||||
|
Triggers a rotation check: if the live audit.ndjson holds events from a
|
||||||
|
past UTC date, rename it to audit-YYYY-MM-DD.ndjson. Idempotent; safe to
|
||||||
|
run alongside the in-server first-append-after-UTC-midnight trigger.
|
||||||
|
|
||||||
|
Authority: ADR 0008 § 5.2.`;
|
||||||
|
|
||||||
|
export async function runCli(argv, opts = {}) {
|
||||||
|
const ioOut = opts.out ?? (s => process.stdout.write(s));
|
||||||
|
const ioErr = opts.err ?? (s => process.stderr.write(s));
|
||||||
|
|
||||||
|
if (argv.includes('--help') || argv.includes('-h')) {
|
||||||
|
ioOut(USAGE + '\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = parseArgv(argv);
|
||||||
|
const allowed = new Set(['olp-home', 'help', 'h']);
|
||||||
|
for (const k of Object.keys(flags)) {
|
||||||
|
if (!allowed.has(k)) {
|
||||||
|
ioErr(`Error: unknown flag --${k}\n${USAGE}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const olpHome = typeof flags['olp-home'] === 'string' ? flags['olp-home'] : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// _maybeRotateAudit is synchronous at v0.3.0 (D52) — rotation must
|
||||||
|
// complete BEFORE the next append so no event straddles the boundary.
|
||||||
|
const result = _maybeRotateAudit({ olpHome });
|
||||||
|
if (result.rotated) {
|
||||||
|
ioOut(`Rotated ${result.fromPath} -> ${result.toPath} (dateUsed=${result.dateUsed}).\n`);
|
||||||
|
} else {
|
||||||
|
ioOut('No rotation needed (live audit is current or absent).\n');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (err) {
|
||||||
|
ioErr(`Error: rotation failed: ${err?.message ?? err}\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main guard
|
||||||
|
const isMain = (() => {
|
||||||
|
try { return import.meta.url === `file://${process.argv[1]}`; }
|
||||||
|
catch { return false; }
|
||||||
|
})();
|
||||||
|
if (isMain) {
|
||||||
|
runCli(process.argv.slice(2)).then(code => process.exit(code));
|
||||||
|
}
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
OLP Dashboard — Phase 3 / D51
|
||||||
|
------------------------------
|
||||||
|
Multi-panel owner-only dashboard per ADR 0008 § 6. Polls
|
||||||
|
/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):
|
||||||
|
1. Per-provider quota / credit pool
|
||||||
|
2. Per-provider 24h request count + cache hit rate + fallback rate
|
||||||
|
3. 30-day spend trend (SVG sparkline; per-provider in tooltip)
|
||||||
|
4. Top 10 fallback chains by trigger count
|
||||||
|
|
||||||
|
No build step, no framework, no external dependencies. Vanilla JS +
|
||||||
|
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
|
||||||
|
receive 401 — non-owner identities will see an error banner.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OLP Dashboard</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 1.5rem; background: #f9fafb; color: #1f2937; }
|
||||||
|
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
|
||||||
|
.meta { color: #6b7280; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; max-width: 1200px; }
|
||||||
|
.panel { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 1rem 1.25rem; }
|
||||||
|
.panel h2 { margin: 0 0 0.75rem; font-size: 1rem; color: #374151; font-weight: 600; }
|
||||||
|
.panel-error { color: #b91c1c; font-style: italic; padding: 0.5rem 0; }
|
||||||
|
.panel-loading { color: #6b7280; font-style: italic; padding: 0.5rem 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
th { font-weight: 600; color: #4b5563; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.banner { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.banner.error { background: #fee2e2; border-color: #ef4444; color: #991b1b; }
|
||||||
|
.sparkline { width: 100%; height: 120px; }
|
||||||
|
.sparkline rect { fill: #3b82f6; }
|
||||||
|
.sparkline rect:hover { fill: #1d4ed8; }
|
||||||
|
.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; }
|
||||||
|
footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>OLP Dashboard</h1>
|
||||||
|
<div id="meta" class="meta">Loading…</div>
|
||||||
|
<div id="banner-slot"></div>
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Quota (per provider)</h2>
|
||||||
|
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Last 24h — request count · cache hit · fallback rate</h2>
|
||||||
|
<div id="panel-24h"><div class="panel-loading">Loading…</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel" style="grid-column: span 2;">
|
||||||
|
<h2>Request count — last 30 days (UTC)</h2>
|
||||||
|
<div id="panel-trend"><div class="panel-loading">Loading…</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel" style="grid-column: span 2;">
|
||||||
|
<h2>Top fallback chains (last 24h)</h2>
|
||||||
|
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer>OLP Dashboard · poll every 30s · paused when tab hidden · v0.3.0-phase3</footer>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
let pollHandle = null;
|
||||||
|
|
||||||
|
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
||||||
|
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'style') node.style.cssText = v;
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag, attrs) {
|
||||||
|
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||||
|
if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuota(data) {
|
||||||
|
const target = document.getElementById('panel-quota');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No providers enabled.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, 'Provider'),
|
||||||
|
el('th', null, 'Available'),
|
||||||
|
el('th', null, 'Status'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const row of data) {
|
||||||
|
const available = row.error ? el('span', { class: 'pill' }, 'unavailable')
|
||||||
|
: row.available === null || row.available === undefined ? el('span', { class: 'pill' }, 'n/a')
|
||||||
|
: el('span', null, fmtNum(row.available));
|
||||||
|
const status = row.error ? el('span', { style: 'color: #b91c1c;' }, row.error)
|
||||||
|
: row.available === null || row.available === undefined ? 'no quota API'
|
||||||
|
: 'ok';
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', null, row.provider),
|
||||||
|
el('td', { class: 'num' }, available),
|
||||||
|
el('td', null, status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render24h(window24h, cacheHit24h) {
|
||||||
|
const target = document.getElementById('panel-24h');
|
||||||
|
target.innerHTML = '';
|
||||||
|
const byProvider = (window24h && window24h.by_provider) || {};
|
||||||
|
const providers = Object.keys(byProvider);
|
||||||
|
if (providers.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No requests in window.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, 'Provider'),
|
||||||
|
el('th', null, 'Requests'),
|
||||||
|
el('th', null, 'Cache hit'),
|
||||||
|
el('th', null, 'Fallback rate'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const p of providers) {
|
||||||
|
const pData = byProvider[p];
|
||||||
|
const hitData = (cacheHit24h && cacheHit24h.by_provider && cacheHit24h.by_provider[p]) || null;
|
||||||
|
const fallbackRate = pData.count > 0 ? pData.fallback_count / pData.count : 0;
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', null, p),
|
||||||
|
el('td', { class: 'num' }, fmtNum(pData.count)),
|
||||||
|
el('td', { class: 'num' }, hitData ? fmtPct(hitData.hit_rate) : 'n/a'),
|
||||||
|
el('td', { class: 'num' }, fmtPct(fallbackRate)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrend(spendTrend30d) {
|
||||||
|
const target = document.getElementById('panel-trend');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(spendTrend30d) || spendTrend30d.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No trend data.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const counts = spendTrend30d.map(d => d.request_count);
|
||||||
|
const maxCount = Math.max(1, ...counts);
|
||||||
|
const width = 800, height = 120, padding = { top: 8, right: 8, bottom: 20, left: 32 };
|
||||||
|
const innerW = width - padding.left - padding.right;
|
||||||
|
const innerH = height - padding.top - padding.bottom;
|
||||||
|
const barGap = 2;
|
||||||
|
const barW = (innerW - barGap * (spendTrend30d.length - 1)) / spendTrend30d.length;
|
||||||
|
const svg = svgEl('svg', { class: 'sparkline', viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: 'xMidYMid meet' });
|
||||||
|
for (let i = 0; i < spendTrend30d.length; i++) {
|
||||||
|
const d = spendTrend30d[i];
|
||||||
|
const h = (d.request_count / maxCount) * innerH;
|
||||||
|
const x = padding.left + i * (barW + barGap);
|
||||||
|
const y = padding.top + innerH - h;
|
||||||
|
const rect = svgEl('rect', { x, y, width: barW, height: Math.max(1, h) });
|
||||||
|
const providerBreakdown = Object.entries(d.by_provider || {}).map(([p, n]) => `${p}: ${n}`).join(', ');
|
||||||
|
const title = svgEl('title');
|
||||||
|
title.textContent = `${d.date} — ${fmtNum(d.request_count)} requests${providerBreakdown ? ' (' + providerBreakdown + ')' : ''}`;
|
||||||
|
rect.appendChild(title);
|
||||||
|
svg.appendChild(rect);
|
||||||
|
}
|
||||||
|
// Y-axis labels (max + min)
|
||||||
|
const maxLabel = svgEl('text', { x: 4, y: padding.top + 10, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
maxLabel.textContent = fmtNum(maxCount);
|
||||||
|
svg.appendChild(maxLabel);
|
||||||
|
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
minLabel.textContent = '0';
|
||||||
|
svg.appendChild(minLabel);
|
||||||
|
// Date labels (first + last only at v0.3.0; mid labels deferred — added if needed by Phase 4 UX feedback)
|
||||||
|
if (spendTrend30d.length > 0) {
|
||||||
|
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
firstDate.textContent = spendTrend30d[0].date.slice(5);
|
||||||
|
svg.appendChild(firstDate);
|
||||||
|
const lastDate = svgEl('text', { x: width - padding.right - 28, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||||
|
lastDate.textContent = spendTrend30d[spendTrend30d.length - 1].date.slice(5);
|
||||||
|
svg.appendChild(lastDate);
|
||||||
|
}
|
||||||
|
target.appendChild(svg);
|
||||||
|
target.appendChild(el('div', { class: 'meta', style: 'margin-top: 0.5rem; font-size: 0.8rem;' },
|
||||||
|
'Hover bars for per-day provider breakdown · y-axis: requests per day (max ' + fmtNum(maxCount) + ')'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChains(chains) {
|
||||||
|
const target = document.getElementById('panel-chains');
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (!Array.isArray(chains) || chains.length === 0) {
|
||||||
|
target.appendChild(el('div', { class: 'panel-loading' }, 'No fallback chains triggered in window.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = el('table', null,
|
||||||
|
el('thead', null, el('tr', null,
|
||||||
|
el('th', null, '#'),
|
||||||
|
el('th', null, 'Chain'),
|
||||||
|
el('th', null, 'Count'),
|
||||||
|
el('th', null, 'First seen'),
|
||||||
|
el('th', null, 'Last seen'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
chains.forEach((c, i) => {
|
||||||
|
tbody.appendChild(el('tr', null,
|
||||||
|
el('td', { class: 'num' }, String(i + 1)),
|
||||||
|
el('td', null, el('span', { class: 'chain' }, c.chain.join(' → '))),
|
||||||
|
el('td', { class: 'num' }, fmtNum(c.count)),
|
||||||
|
el('td', null, c.first_seen || ''),
|
||||||
|
el('td', null, c.last_seen || ''),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
target.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const slot = document.getElementById('banner-slot');
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.appendChild(el('div', { class: 'banner error' }, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
document.getElementById('banner-slot').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
const res = await fetch('/v0/management/dashboard-data', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
showError('401 — owner-tier OLP key required. The dashboard is owner-only_block (ADR 0008 §8). Pass `Authorization: Bearer <owner-token>` via a proxy/extension; OLP itself doesn\'t accept browser cookies. Common path: SSH-tunnel + curl + tee the dashboard-data JSON, OR use a browser extension that adds the header.');
|
||||||
|
throw new Error('owner_required');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
showError('Dashboard data fetch failed: HTTP ' + res.status);
|
||||||
|
throw new Error('http_' + res.status);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await fetchDashboardData();
|
||||||
|
clearError();
|
||||||
|
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
|
||||||
|
document.getElementById('meta').textContent =
|
||||||
|
'Last refresh: ' + generated.toLocaleString() + ' · next in ~30s';
|
||||||
|
renderQuota(data.quota);
|
||||||
|
render24h(data.window_24h, data.cache_hit_24h);
|
||||||
|
renderTrend(data.spend_trend_30d);
|
||||||
|
renderChains(data.top_fallback_chains_24h);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollHandle !== null) return;
|
||||||
|
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollHandle === null) return;
|
||||||
|
clearInterval(pollHandle);
|
||||||
|
pollHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5).
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') stopPolling();
|
||||||
|
else { refresh(); startPolling(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial fetch + start poll.
|
||||||
|
refresh().finally(startPolling);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -263,7 +263,9 @@ Field origin:
|
|||||||
|
|
||||||
**No PII.** Audit deliberately captures NO request body, NO response body, NO IR-message content. Hash + shape only. This is a personal/family deployment property; do not relax without a separate ADR amendment.
|
**No PII.** Audit deliberately captures NO request body, NO response body, NO IR-message content. Hash + shape only. This is a personal/family deployment property; do not relax without a separate ADR amendment.
|
||||||
|
|
||||||
**Rotation.** Phase 2 does NOT rotate `audit.ndjson`. Rotation policy lands in Phase 3 alongside Dashboard / audit query work (§ 12).
|
**`tried_providers` semantics (clarification, D53 / 2026-05-25).** The field captures the list of providers the server **actually dispatched a spawn against** for this request. A provider that was configured in the chain but filtered out by `providers_enabled` gating (resulting in 403 `key_no_provider_access`) is NOT included — the key didn't try the provider, the gate did. On the 403 path `tried_providers` is the empty array. The configured-but-blocked chain providers appear in the human-readable error message returned to the client but are intentionally NOT surfaced in the audit event, so downstream queries like "which providers did key X actually call" stay accurate. This semantic was implicit in the D45 implementation (where the field was set to the original chain on 403, misrepresenting "tried"); D53 corrects the implementation + amends this section to spell out the intent.
|
||||||
|
|
||||||
|
**Rotation.** Phase 2 does NOT rotate `audit.ndjson`. Rotation policy ships in Phase 3 — daily rotation via `lib/audit.mjs` `_maybeRotateAudit` synchronous trigger on first append after UTC date change + optional `bin/olp-audit-rotate.mjs` external cron. See ADR 0008 § 5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# ADR 0008 — Dashboard + Audit Query Layer (Phase 3)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-25
|
||||||
|
- **Status:** Accepted (D48, design-only — implementation D-days D49–D54 follow; Phase 3 close = v0.3.0)
|
||||||
|
- **Authors:** project maintainer (with AI drafting assistance)
|
||||||
|
- **Related:**
|
||||||
|
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
|
||||||
|
- ADR 0007 § 12 (Phase 3+ out-of-scope: Dashboard, audit query layer, rotation) — this ADR opens those deferrals
|
||||||
|
- ADR 0007 § 13 (Option 3 hybrid migration to SQLite) — explicitly **NOT** triggered by Phase 3; in-memory ndjson scan is the v0.3.0 query model
|
||||||
|
- ADR 0007 § 7 (Identity classes) — Dashboard auth gating reuses owner-vs-non-owner pattern (Dashboard is owner-only)
|
||||||
|
- ADR 0007 § 8 (Audit ndjson schema) — the data source the query layer reads
|
||||||
|
- ADR 0004 Amendment 2 (soft triggers deferred to v1.x) — quota panel sources from `provider.quotaStatus()` which is a contract method; per-provider returns what it can or `null`
|
||||||
|
- D45 reviewer P2 deferral on `tried_providers` semantics — addressed in this Phase as D53 (separate D-day; not in ADR 0008 scope)
|
||||||
|
- **Phase 3 kickoff authority:** maintainer "go" + standing-autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a` — the grant explicitly excludes Phase 3+ as needing new authorization; the "go" supplied that)
|
||||||
|
- **Lanes pinned by maintainer 2026-05-25 (Phase 3 kickoff brief):**
|
||||||
|
- Lane 1 Dashboard tech stack: **A** — static HTML + vanilla JS + fetch (no build step; matches OLP "no bundler" ethos)
|
||||||
|
- Lane 2 Audit query model: **A** — in-memory scan of audit ndjson per request (O(N) per query; family-scale acceptable; SQLite deferred to Option 3 trigger per ADR 0007 § 13)
|
||||||
|
- Lane 3 Audit rotation: **B** — daily rotation, files named `audit-YYYY-MM-DD.ndjson` (UTC date)
|
||||||
|
- Lane 4 Refresh: **A** — page poll every 30s (no SSE infra introduction)
|
||||||
|
- Lane 5 Dashboard scope: **B** — full per spec § 4.6 (quota + per-provider counts/cache/fallback last 24h + multi-provider spend trend last 30d + top fallback chains by trigger count)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
OLP v0.2.0 ships per-key audit ndjson at `~/.olp/logs/audit.ndjson` (ADR 0007 § 8) but provides no aggregate query surface or visualization. The audit file grows unbounded, and operators must `tail`/`grep` to observe basic facts ("which provider served the most requests today", "what's my cache hit rate", "how often did the chain fall back to OpenAI"). Phase 3 closes this gap with:
|
||||||
|
|
||||||
|
1. **`lib/audit-query.mjs`** — an in-memory aggregator that scans `~/.olp/logs/audit-*.ndjson` files in a configurable time window and returns shaped summaries.
|
||||||
|
2. **`server.mjs` `/v0/management/*` endpoints** — three owner-only JSON endpoints exposing the aggregate data: `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`.
|
||||||
|
3. **`dashboard.html`** — a single static HTML file served from `/dashboard` (owner-only) that fetches the JSON endpoints, renders 4 panels, and polls every 30s.
|
||||||
|
4. **Daily audit rotation** — at UTC midnight (or on first append after a UTC-date change), the live `audit.ndjson` is renamed to `audit-YYYY-MM-DD.ndjson` and a fresh `audit.ndjson` opens. Cross-file queries handle the rolling 30-day window.
|
||||||
|
|
||||||
|
Phase 3 deliberately **does not** add a build step, a database, or per-key UI write surface. The first three are out of scope (Option 3 hybrid is § 13's forward path; SQLite has a Node-baseline blocker per § 11). The last is a security surface that warrants a separate review pass (Phase 4+).
|
||||||
|
|
||||||
|
Phase 3 is the natural home for OCP's `dashboard.html` port (v0.1 spec § 4.6 — "Port OCP's `dashboard.html` with multi-provider support"). OCP's dashboard was single-provider; OLP's is multi-provider, which is the substantive change. The HTML structure is otherwise lifted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
The five lanes above are normative. The deviation lattice they sit in:
|
||||||
|
|
||||||
|
| Lane | Pinned | Rejected (why) |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. Tech stack** | Static HTML + vanilla JS + fetch | SSR (Node template literals) — adds server-side render path; CSP harder. SPA — adds build step, violates ethos. |
|
||||||
|
| **2. Query model** | In-memory scan of `audit-*.ndjson` per request | SQLite indexed mirror — touches ADR 0007 § 13 migration → engines bump prerequisite. In-memory rolling aggregate — complex (per-write incremental + window expiry), correctness risk. |
|
||||||
|
| **3. Rotation** | Daily UTC rotation (`audit-YYYY-MM-DD.ndjson`) | No rotation — single file grows unbounded at family scale ~10–100 lines/day but a year is ~10–50k lines, still ok but no natural query unit. Size-based (100MB / keep 5) — equivalent complexity, query range less intuitive. |
|
||||||
|
| **4. Refresh** | Page poll every 30s | SSE push — adds server-side subscriber state; not justified at family scale. Static-at-load — UX too poor. |
|
||||||
|
| **5. Dashboard scope** | Full per spec § 4.6 | Minimal (quota + cache + fallback only) — spec is already drafted; full is ~1 D-day more for trend + top-chains panels. Plus key-mgmt UI — security surface delta, Phase 4+. |
|
||||||
|
|
||||||
|
**Phase 3 does not** introduce: a database, a build step, an SPA framework, SSE for dashboard, or web-side key management. Each is a future-phase concern (Option 3 hybrid; SSE for dashboard if poll latency becomes pain; key-mgmt UI after a security review pass).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Storage layout (`~/.olp/logs/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.olp/logs/
|
||||||
|
audit.ndjson — live append target (Phase 2 D45)
|
||||||
|
audit-2026-05-24.ndjson — yesterday (after rotation)
|
||||||
|
audit-2026-05-23.ndjson
|
||||||
|
audit-2026-05-22.ndjson
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Files are append-only (no in-place edits). Rotation atomically renames the live file and opens a fresh one. All files have mode 0600; the `logs/` directory is 0700.
|
||||||
|
|
||||||
|
**Retention policy at v0.3.0:** unbounded by default (operator manages disk). A Phase 3+ amendment may add automatic retention (`olp_audit_max_days` config) once an observed operational need exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Audit query layer (`lib/audit-query.mjs`)
|
||||||
|
|
||||||
|
A new module that reads the rotated + live audit files in a date range and returns aggregate summaries. Per-call O(N) where N = total lines in the date range (family scale: thousands per day = trivial).
|
||||||
|
|
||||||
|
### 4.1 Public API
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Read all audit lines in [startMs, endMs); returns iterator of parsed events.
|
||||||
|
// Skips malformed lines (logs warn) so a corrupted day doesn't kill the query.
|
||||||
|
export function* readAuditWindow({ startMs, endMs, olpHome }): Iterator<AuditEvent>;
|
||||||
|
|
||||||
|
// Aggregate request shape over a window. Returns:
|
||||||
|
// {
|
||||||
|
// window: { startMs, endMs },
|
||||||
|
// request_count, status_2xx, status_4xx, status_5xx,
|
||||||
|
// by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, fallback_count } },
|
||||||
|
// by_owner_tier: { owner: N, guest: N, anonymous: N },
|
||||||
|
// by_path: { '/v1/chat/completions': N, '/v1/models': N },
|
||||||
|
// median_latency_ms, p95_latency_ms,
|
||||||
|
// }
|
||||||
|
export function aggregateRequests({ windowMs, olpHome }): RequestAggregate;
|
||||||
|
|
||||||
|
// Top-N fallback chains by trigger count in window. Returns sorted array:
|
||||||
|
// [{ chain: ['anthropic', 'openai'], count: 42, first_seen, last_seen }, ...]
|
||||||
|
export function topFallbackChains({ windowMs, limit, olpHome }): FallbackChainSummary[];
|
||||||
|
|
||||||
|
// Daily series of request_count + latency_median over N days. Returns sorted array:
|
||||||
|
// [{ date: '2026-05-22', request_count, median_latency_ms, by_provider }, ...]
|
||||||
|
export function spendTrendDaily({ days, olpHome }): DailySpendEntry[];
|
||||||
|
|
||||||
|
// Cache hit rate snapshot (in-memory cacheStore stats + audit-derived numerator).
|
||||||
|
// Differs from /cache/stats: that returns the live in-memory CacheStore stats;
|
||||||
|
// this is the audit-side derived rate over the window.
|
||||||
|
export function cacheHitRateWindow({ windowMs, olpHome }): CacheHitRateSummary;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Window semantics
|
||||||
|
|
||||||
|
`windowMs` is a duration ending at "now"; `[now - windowMs, now)`. The implementation walks files whose date overlaps that range: today's `audit.ndjson` always; `audit-YYYY-MM-DD.ndjson` for each prior date in range. A line is included only if its `ts` (ISO-8601) falls in the window.
|
||||||
|
|
||||||
|
For the 30-day spend trend, `windowMs = 30 * 86400 * 1000`. The implementation buckets per UTC day and returns one entry per day, including days with zero requests (sparse-fill).
|
||||||
|
|
||||||
|
### 4.3 PII discipline
|
||||||
|
|
||||||
|
Per ADR 0007 § 8, audit events contain no message content, no response content, no raw tokens. The query layer relays only the schema fields. It MUST NOT introduce derived fields that reveal content (e.g., "first 50 chars of prompt").
|
||||||
|
|
||||||
|
### 4.4 Error handling
|
||||||
|
|
||||||
|
- Missing file → empty iteration (not an error).
|
||||||
|
- Malformed JSON line → log warn `audit_query_skip_malformed` + skip; continue.
|
||||||
|
- File-read error (EACCES, etc.) → throw to caller; the dashboard endpoint surfaces 500 with diagnostic message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Audit rotation
|
||||||
|
|
||||||
|
### 5.1 Trigger
|
||||||
|
|
||||||
|
Rotation fires on the **first append after a UTC date change**. Implementation lives in `lib/audit.mjs` (extended at D49). On each `appendAuditEvent` call:
|
||||||
|
|
||||||
|
1. Compute `today = new Date().toISOString().slice(0, 10)` (e.g., `'2026-05-25'`).
|
||||||
|
2. Read a module-scoped `_currentDate` cached at startup.
|
||||||
|
3. If `today !== _currentDate` AND `audit.ndjson` exists AND it is non-empty:
|
||||||
|
- Rename `audit.ndjson` → `audit-${_currentDate}.ndjson` (the previous day's date).
|
||||||
|
- Set `_currentDate = today`.
|
||||||
|
- Continue with the append (new `audit.ndjson` opens via append-create).
|
||||||
|
|
||||||
|
The check is per-call (microsecond cost). The rename is the only filesystem heavy op and fires once per UTC day.
|
||||||
|
|
||||||
|
### 5.2 External-cron alternative (`bin/olp-audit-rotate.mjs`)
|
||||||
|
|
||||||
|
An auxiliary script is shipped at D52 for operators who prefer cron-driven rotation (e.g., to rotate exactly at 00:00:00 UTC rather than "first request after midnight"). The script does the same rename + state-bump logic but can be invoked from a host cron / launchd job. The in-server check remains as a safety net; both can coexist.
|
||||||
|
|
||||||
|
### 5.3 Concurrent-rotation safety
|
||||||
|
|
||||||
|
In-process: the rotation logic is wrapped in a per-process lock (`Map<key='audit-rotate', Promise>`) so two concurrent `appendAuditEvent` calls don't both attempt the rename. External cron + in-server check: the in-server check sees the rename has already happened (file with today's date already exists if cron beat it); the no-op fallback is "if `audit.ndjson` exists, append; else create + append" — POSIX semantics.
|
||||||
|
|
||||||
|
### 5.4 Renamed-file query path
|
||||||
|
|
||||||
|
The query layer (§ 4) walks `audit-${date}.ndjson` files for any date in the window. Today's file is always `audit.ndjson` (not renamed yet); yesterday + prior are date-suffixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dashboard panels (per spec § 4.6, Lane 5 = B full)
|
||||||
|
|
||||||
|
`dashboard.html` is a single static file served from `/dashboard`. Renders 4 panels in a 2×2 grid:
|
||||||
|
|
||||||
|
### 6.1 Panel 1 — Per-provider quota / credit pool
|
||||||
|
|
||||||
|
For each loaded provider, calls `provider.quotaStatus()` (ADR 0002 Provider contract). Returns whatever the provider can report (e.g., Anthropic Plan limits remaining, Codex credit pool balance) OR `null` (provider opts out — Phase 2 mistral has no quota API).
|
||||||
|
|
||||||
|
Each row shows:
|
||||||
|
- Provider key + display name
|
||||||
|
- Quota remaining / quota total (or "n/a" if null)
|
||||||
|
- Last poll timestamp
|
||||||
|
|
||||||
|
### 6.2 Panel 2 — Per-provider request count + cache hit rate + fallback rate (last 24h)
|
||||||
|
|
||||||
|
Uses `aggregateRequests({ windowMs: 86400 * 1000 })`. One row per provider showing:
|
||||||
|
- Request count (total served by that provider, regardless of chain position)
|
||||||
|
- Cache hit rate (% of requests where `cache_status === 'hit'`)
|
||||||
|
- Fallback rate (% of requests where `fallback_hops > 0`)
|
||||||
|
- 5xx error rate (% of requests where `status_code >= 500`)
|
||||||
|
|
||||||
|
### 6.3 Panel 3 — Multi-provider unified spend trend (last 30 days)
|
||||||
|
|
||||||
|
Uses `spendTrendDaily({ days: 30 })`. Shows a sparkline-style chart (vanilla SVG, no library) with:
|
||||||
|
- X axis: 30 daily buckets
|
||||||
|
- Y axis: request count per day (stacked by provider color)
|
||||||
|
- Hover tooltip: per-day breakdown by provider
|
||||||
|
|
||||||
|
Note: "spend trend" is the spec's term — at v0.3.0 we don't have provider-side cost integration (Anthropic Plan is flat-rate per Anthropic 2026-06-15 split per the learning memory). So "spend" is proxied by request count. A future ADR may add cost weights per provider when commercial cost-tracking lands.
|
||||||
|
|
||||||
|
### 6.4 Panel 4 — Top fallback chains by trigger count
|
||||||
|
|
||||||
|
Uses `topFallbackChains({ windowMs: 86400 * 1000, limit: 10 })`. Lists top 10 chains:
|
||||||
|
- Chain shape (e.g., `anthropic → openai`)
|
||||||
|
- Trigger count
|
||||||
|
- First / last seen timestamps
|
||||||
|
|
||||||
|
### 6.5 Refresh model (Lane 4 = A)
|
||||||
|
|
||||||
|
The dashboard sets a 30s `setInterval` that calls `fetch('/v0/management/dashboard-data')` + updates DOM in place (no full reload). Initial fetch on page load. The interval pauses when the page is hidden (via `document.visibilityState` listener) to avoid useless background polls.
|
||||||
|
|
||||||
|
### 6.6 Localhost-bound by default
|
||||||
|
|
||||||
|
The dashboard is served from the existing OLP HTTP port (default 3456) which is already bound to `127.0.0.1` per `server.mjs` startup (`server.listen(PORT, '127.0.0.1', ...)`). No additional binding logic. Remote operators access via SSH tunnel; ADR 0007 § 7 owner-only auth provides the per-request gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Server endpoints (D50)
|
||||||
|
|
||||||
|
All endpoints are owner-only per ADR 0007 § 7 (owner-tier validation through `authenticate`). Non-owner identities get 401 / 403 per existing patterns; anonymous (when `allow_anonymous: true`) gets 401 (these are management endpoints, not user-facing).
|
||||||
|
|
||||||
|
### 7.1 `GET /dashboard`
|
||||||
|
|
||||||
|
Serves `dashboard.html`. Owner-only gated. Content-Type `text/html; charset=utf-8`. Static file read once at server startup + cached in memory (small, no need to re-read per request).
|
||||||
|
|
||||||
|
### 7.2 `GET /v0/management/dashboard-data`
|
||||||
|
|
||||||
|
Returns the JSON payload the dashboard's 30s poll consumes. Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"generated_at": "<ISO-8601>",
|
||||||
|
"window_24h": <RequestAggregate from §4.1>,
|
||||||
|
"quota": [
|
||||||
|
{ "provider": "anthropic", "quota_remaining": 1234, "quota_total": 5000, "polled_at": "<ISO-8601>" },
|
||||||
|
{ "provider": "openai", "quota_remaining": null }
|
||||||
|
],
|
||||||
|
"spend_trend_30d": <DailySpendEntry[] from §4.1>,
|
||||||
|
"top_fallback_chains_24h": <FallbackChainSummary[] from §4.1>,
|
||||||
|
"cache_stats": <stats from server.mjs cacheStore.stats() — global aggregate>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `GET /v0/management/quota`
|
||||||
|
|
||||||
|
Returns just the quota array (subset of dashboard-data; useful for scripted monitoring).
|
||||||
|
|
||||||
|
### 7.4 `GET /cache/stats`
|
||||||
|
|
||||||
|
Returns the live in-memory `cacheStore.stats()` shape. Planning authority is **OLP v0.1 spec § 4.6** (which names `/cache/stats` explicitly); ADR 0005's `Consequences/Mitigations` paragraph (~ line 279) references it as the monitoring surface for per-`(provider, model)` cache hit-rate breakdown.
|
||||||
|
|
||||||
|
**Shape gap to resolve at D50.** The current `cacheStore.stats()` in `lib/cache/store.mjs:320-350` returns `{ hits, misses, size, inflightCount }` — global aggregate only, no per-`(provider, model)` breakdown. If D50 reveals the shape is insufficient for Panel 2 (per-provider 24h cache hit rate, currently sourced from `aggregateRequests` audit-side rather than `cacheStore.stats`), the dashboard endpoint is satisfied. If a future panel needs the per-`(provider, model)` breakdown that spec § 4.6 implies, D50 amends the store shape + an ADR 0005 amendment fires at that time. Phase 3 acceptance criteria do not require the breakdown.
|
||||||
|
|
||||||
|
### 7.5 Audit on management endpoints
|
||||||
|
|
||||||
|
All four endpoints append an audit row via the existing `appendAuditEvent` pattern. Path values are `/dashboard` / `/v0/management/dashboard-data` / `/v0/management/quota` / `/cache/stats`. The 30s poll generates 2880 dashboard rows per day per owner — manageable at family scale, but noted as a knob (a future amendment may suppress audit for these paths if they become noise-dominant).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Auth gating
|
||||||
|
|
||||||
|
Reuses ADR 0007 § 7 owner-vs-non-owner model + introduces a second gating mode.
|
||||||
|
|
||||||
|
**Two gating modes (this ADR formalizes the distinction):**
|
||||||
|
|
||||||
|
- **`owner_only_trim`** (Phase 2 / D46 model) — non-owner identities receive a 200 response with a trimmed payload (e.g., `/health` returns `{ ok, version }` only). Used when the endpoint has a baseline payload that is safe to share with all identities and an enriched payload only for owners.
|
||||||
|
- **`owner_only_block`** (Phase 3 / D48 new) — non-owner identities receive `401 invalid_or_revoked_key` (or `401 auth_required` if no token). Used when the entire payload is sensitive and there is no safe baseline to share (Dashboard quota stats, fallback chains by trigger, etc. all reveal operational behaviour that should not leak to non-owner identities).
|
||||||
|
|
||||||
|
The four new endpoints (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`) are `owner_only_block`. `/health` remains `owner_only_trim`.
|
||||||
|
|
||||||
|
The owner_only_endpoints config gains four entries; the gating-mode distinction is implementation-side (the handler decides whether to trim or block based on the endpoint). Server startup defaults `owner_only_endpoints` to include `/health` + the four new ones (Phase 3 default; operator can opt-out per-endpoint via config). Pre-Phase-3 deployments with `owner_only_endpoints: ['/health']` continue to work — the new endpoints will 401 for non-owner under that legacy config because the handler is `owner_only_block`-mode regardless of the config list (the config controls /health's trim/full toggle only; the management endpoints are not opt-out-able to a non-401 response).
|
||||||
|
|
||||||
|
`401` shapes match Phase 2 / D45 pattern: JSON `{ error: { message, type } }` with `type: 'auth_required'` or `'invalid_or_revoked_key'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Failure modes + graceful degradation
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `audit.ndjson` absent (fresh install) | Empty arrays in all aggregates; dashboard shows "No requests in window" panels |
|
||||||
|
| `audit-YYYY-MM-DD.ndjson` corrupted (one bad line) | Skip line + log warn; continue; dashboard rendering unaffected |
|
||||||
|
| Provider `quotaStatus()` throws | Panel shows that row as `quota: "error"`; other providers' rows render normally |
|
||||||
|
| Dashboard `/v0/management/dashboard-data` query >5s | Dashboard JS shows "Loading…" with a timeout; second poll attempts after 30s |
|
||||||
|
| `audit.ndjson` rotation fails (rename EACCES) | `appendAuditEvent` warn `audit_rotate_failed` + continues appending to the un-rotated file; next call retries the rotation |
|
||||||
|
| File handle limit hit during 30-day query (many files open) | Query reads one file at a time (no parallel reads); never opens >2 simultaneously |
|
||||||
|
|
||||||
|
The dashboard degrades visibly (per-panel error states) rather than failing whole-page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptance criteria
|
||||||
|
|
||||||
|
Implementation D-days (D49+) MUST land tests covering:
|
||||||
|
|
||||||
|
1. **`readAuditWindow`** correctly iterates events from today's `audit.ndjson` + N prior rotated files within the window.
|
||||||
|
2. **`readAuditWindow`** skips malformed lines without throwing; logs warn for each.
|
||||||
|
3. **`aggregateRequests`** correctly counts by provider + cache_status + owner_tier + path; correctly computes median + p95 latency.
|
||||||
|
4. **`topFallbackChains`** returns sorted by count descending; ties broken by first-seen timestamp ascending.
|
||||||
|
5. **`spendTrendDaily`** sparse-fills zero-request days; window respects UTC day boundaries.
|
||||||
|
6. **Daily rotation** — writing past UTC midnight renames `audit.ndjson` → `audit-<yesterday>.ndjson` and continues appending to a fresh `audit.ndjson`.
|
||||||
|
7. **Cross-file query** — a 30-day window with mixed rotated files returns correctly merged results.
|
||||||
|
8. **Concurrent rotation safety** — N concurrent `appendAuditEvent` calls during a UTC date change result in exactly one rename + all lines append to the correct file.
|
||||||
|
9. **`GET /dashboard`** returns 200 HTML to owner; 401 to non-owner. This includes the case where `allow_anonymous: true` AND no Authorization header is presented: the authenticate middleware produces an anonymous identity, the `owner_only_block` mode then rejects with 401 (per § 8 — anonymous is non-owner; management endpoints block, do not trim). When `allow_anonymous: false` + no header, 401 fires earlier at the authenticate middleware itself. Test must cover both cases.
|
||||||
|
10. **`GET /v0/management/dashboard-data`** returns 200 JSON to owner with all required fields populated.
|
||||||
|
11. **`GET /cache/stats`** returns 200 JSON to owner with the live in-memory cache stats shape.
|
||||||
|
12. **Dashboard HTML smoke** — fetched via test http client + parsed → has the 4 panel containers + 30s poll script; no JS console errors when loaded in a real browser (manual or playwright; manual is acceptable at Phase 3).
|
||||||
|
13. **Audit on management endpoints** — calling `/v0/management/dashboard-data` appends an audit row with `path: '/v0/management/dashboard-data'` and `status_code: 200`.
|
||||||
|
14. **Graceful degradation** — when a provider's `quotaStatus()` throws, the dashboard endpoint still returns 200 with that provider's quota row showing `"quota_remaining": null` and an error indicator.
|
||||||
|
15. **PII guard** — every aggregate query function asserts at the test level that returned data does NOT include any message content; the `prompt`/`messages`/`response`/`content` fields MUST NEVER appear in any output shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Forward path (Phase 4+)
|
||||||
|
|
||||||
|
Items deliberately deferred:
|
||||||
|
|
||||||
|
- **SQLite migration (Option 3 hybrid)** — trigger: query latency >2s on a typical owner session, OR Dashboard usage scales beyond family (>5 owners polling). Preconditions per ADR 0007 § 13: engines bump + CI matrix change as a separate prior PR.
|
||||||
|
- **SSE push for dashboard live updates** — trigger: 30s poll feels stale, OR operator wants real-time view of streaming requests. Reuses existing streaming infra from ADR 0005 Amendment 8 (v1.x streaming SF when it ships).
|
||||||
|
- **Key-mgmt UI from dashboard** — owner can create/revoke/edit keys from the web UI rather than CLI. Out of Phase 3 because (a) it adds a write surface to the dashboard requiring careful CSRF handling, (b) security review of the auth flow is non-trivial, (c) the CLI surface from D47 covers the same use cases.
|
||||||
|
- **Cost weights per provider** — once provider-side cost tracking is feasible, "spend trend" can show actual dollars. At v0.3.0 it's a request-count proxy.
|
||||||
|
- **Audit retention / max-days policy** — currently unbounded; operator manages disk. A Phase 3+ amendment adds `audit_max_days` config when an operational need emerges.
|
||||||
|
- **Per-key dashboard views** — owner sees aggregate; per-key drill-down is a future amendment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope (explicitly NOT in Phase 3)
|
||||||
|
|
||||||
|
- Per-key per-provider auth artifact mapping (ADR 0007 § 12; Phase 4+).
|
||||||
|
- `tried_providers` schema semantics fix on `key_no_provider_access` 403 — D45 reviewer P2 deferral. Tracked as a Phase 3 implementation D-day (D53) but NOT part of ADR 0008; documented in ADR 0004 amendment or ADR 0007 § 8 amendment at D53.
|
||||||
|
- All ADR 0007 § 12 deferrals other than Dashboard + audit query layer + rotation.
|
||||||
|
- Externally-visible Dashboard (anything bound to 0.0.0.0 / public). Operator SSH-tunnels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Phase 3 sprint shape
|
||||||
|
|
||||||
|
| D-day | Deliverable | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| **D48** | This ADR (0008 draft) | ADR-only |
|
||||||
|
| **D49** | `lib/audit-query.mjs` + Suite 23 unit tests | impl |
|
||||||
|
| **D50** | `server.mjs` `/v0/management/*` endpoints + `/dashboard` route + Suite 24 HTTP tests | impl |
|
||||||
|
| **D51** | `dashboard.html` + render JS + 30s poll | impl |
|
||||||
|
| **D52** | Audit daily rotation (`lib/audit.mjs` extension + `bin/olp-audit-rotate.mjs` + Suite 25 rotation tests) | impl |
|
||||||
|
| **D53** | `tried_providers` schema fix (D45 P2 deferral; small) | impl |
|
||||||
|
| **D54** | E2E browser smoke (manual or playwright) + AGENTS / README polish | tests + docs |
|
||||||
|
| **D55** | Phase 3 close → v0.3.0 (release-kit PR per `phase_close_trigger`) | release |
|
||||||
|
|
||||||
|
Each D-day = implementor + fresh-context opus reviewer per Iron Rule 10. Estimated wall-clock: similar to Phase 2 cadence (1 intense session per D-day under standing autopilot).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- Operators get an at-a-glance view of OLP's behaviour (was a tail/grep exercise pre-Phase-3).
|
||||||
|
- Audit layer becomes queryable, not just append-only; `lib/audit-query.mjs` is reusable for future CLI tools (`olp-audit search` etc.).
|
||||||
|
- Daily rotation bounds per-file size + creates a natural archival unit.
|
||||||
|
- Owner-only gating reuses Phase 2 auth model (no new auth surface).
|
||||||
|
- v0.1 spec § 4.6 Dashboard requirements are met without introducing a build step / database / framework.
|
||||||
|
|
||||||
|
**Negative / trade-offs:**
|
||||||
|
|
||||||
|
- In-memory ndjson scan is O(N) per query; if audit grows to millions of lines (single-host years), the 30-day query becomes slow. Mitigation: ADR 0007 § 13 Option 3 hybrid is the documented next step; trigger is observed slowness.
|
||||||
|
- 30s poll generates baseline traffic when dashboard is open (2880 management requests/day per owner). Not a real cost but worth observing.
|
||||||
|
- Audit rotation is "first append after UTC midnight" which means a server with zero requests overnight rotates lazily (first request of the new day triggers it). External cron at D52 covers the strict-midnight case.
|
||||||
|
- No automatic audit retention. A multi-year-running server accumulates files; operator manages.
|
||||||
|
|
||||||
|
**Reversibility:**
|
||||||
|
|
||||||
|
- Dashboard is a static file + 4 endpoints. Removable in a future revert PR if Phase 3 retrospectively proves unwanted.
|
||||||
|
- Audit rotation is additive — disabling reverts to single-file behaviour without code change (operator never invokes the cron + the in-server rotation can be guarded by a config flag).
|
||||||
|
- The `lib/audit-query.mjs` module is consumed by Dashboard endpoints + can be used standalone; removing it requires unrelated endpoint surgery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authority citations
|
||||||
|
|
||||||
|
- **OLP v0.1 spec § 4.6 + § 4.7** (Dashboard + observability endpoints) — at `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations.
|
||||||
|
- **OCP `dashboard.html`** (prior-art reference for the multi-panel HTML structure) — at `~/ocp/dashboard.html` on the maintainer's workstation; OCP production reference.
|
||||||
|
- **ADR 0007 §§ 7 / 8 / 12 / 13** (owner-gating model; audit ndjson schema; Phase 3 scope opening; SQLite forward path).
|
||||||
|
- **ADR 0002** (Provider contract — `quotaStatus` method that Panel 1 consumes).
|
||||||
|
- **OLP v0.1 spec § 4.6** (planning authority for `/cache/stats` endpoint name + Dashboard requirements). ADR 0005's `Consequences/Mitigations` paragraph references the endpoint as the monitoring surface for per-`(provider, model)` cache hit-rate breakdown; that breakdown is a Phase 4+ amendment trigger if needed (see § 7.4 above).
|
||||||
|
- **ADR 0004 Amendment 5** (D40 X-OLP-Fallback-Detail — top-fallback-chains panel data shape lineage).
|
||||||
|
- **Standing-autopilot grant** (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`) — Phase 3 kickoff via maintainer "go" + lane pin.
|
||||||
|
- **Phase 2 kickoff handoff pattern** (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`) — this ADR follows the same structure.
|
||||||
|
- **CLAUDE.md `release_kit.phase_rolling_mode current_phase: Phase 3`** — confirms this ADR lands in the Phase 3 sprint.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# ADR 0009 — Anthropic Interactive-Mode Path (Placeholder)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-25
|
||||||
|
- **Status:** Draft (Placeholder — blocked on OCP ADR 0007 P0 experiment outcome; no implementation D-day scheduled until P0 lands)
|
||||||
|
- **Authors:** project maintainer (with AI advisory drafting)
|
||||||
|
- **Related:**
|
||||||
|
- **OCP ADR 0007** (Interactive-Mode Execution Pool, stream-json) — at `~/ocp/docs/adr/0007-interactive-mode-pool.md` on the maintainer's workstation. Pin reference at the time of this writing: OCP ADR 0007 is Draft status pending the same P0 outcome.
|
||||||
|
- OLP ADR 0001 (Project Founding) — establishes the 2026-06-15 Anthropic billing-split trigger that motivated OLP's multi-provider posture in the first place.
|
||||||
|
- OLP ADR 0006 (Provider Inclusion / Risk Tier Framework) — anthropic is currently a Tier-D Candidate; this ADR amends the operational shape of that plugin if/when P0 succeeds.
|
||||||
|
- OLP ADR 0007 (Multi-Key Auth) — Phase 2 design; per-key cache + audit layer that any future interactive-mode implementation must continue to satisfy.
|
||||||
|
- OLP ADR 0008 (Dashboard + Audit Query) — Phase 3 design; any interactive-mode change must not regress the Dashboard's per-provider observability fields.
|
||||||
|
- **Standing autopilot grant note:** Phase 4 is a "new authorization required" scope per `~/.cc-rules/memory/auto/standing_autopilot_phase_2.md`. This placeholder ADR is recorded NOT as implementation work but as the maintainer's "do not forget this when planning Phase 4" anchor. No implementation D-day is scheduled until P0 lands AND maintainer issues a Phase 4 "go" specific to this ADR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
### 1.1 The triggering external work
|
||||||
|
|
||||||
|
OCP shipped ADR 0007 (`docs/adr/0007-interactive-mode-pool.md` in `dtzp555-max/ocp`) on 2026-05-25, draft status. The ADR designs a dual-path execution model for the post-2026-06-15 Anthropic billing split:
|
||||||
|
|
||||||
|
- **Current `claude -p` path**: programmatic billing → Agent SDK $100/month credit pool (~20–50 heavy coding sessions/month).
|
||||||
|
- **Proposed interactive-mode path**: spawn Claude without `-p`, communicate via either (Transport A) piped NDJSON over stdio or (Transport B) `node-pty` PTY — possibly classified as interactive billing → subscription pool.
|
||||||
|
|
||||||
|
The OCP team accepted the *concept* but rejected an external contributor's PR #101 implementation (tmux + hook-file polling + `--dangerously-skip-permissions`) on alignment + security grounds, then drafted ADR 0007 as the clean redesign.
|
||||||
|
|
||||||
|
### 1.2 Why this is OLP's concern
|
||||||
|
|
||||||
|
OLP's founding premise (ADR 0001) was that OCP would become uneconomical post-2026-06-15, motivating a multi-provider hedge. OLP's `lib/providers/anthropic.mjs` today uses the same `claude -p` invocation OCP uses → same billing consequence post-2026-06-15.
|
||||||
|
|
||||||
|
If OCP's ADR 0007 P0 experiment confirms that an interactive-mode spawn (Transport A or B) bills against subscription rather than Agent SDK credit, the implementation pattern is directly portable to OLP's anthropic provider plugin. OLP would inherit the same billing benefit without needing to commission an independent P0.
|
||||||
|
|
||||||
|
If P0 fails for both transports, OLP's anthropic provider remains stuck on `-p` post-June-15; the multi-provider routing (OpenAI Codex, Mistral) becomes the operational mitigation, exactly per OLP's original founding logic.
|
||||||
|
|
||||||
|
### 1.3 The unverified premise (binding caveat)
|
||||||
|
|
||||||
|
Per OCP ADR 0007 § "Unverified Premise":
|
||||||
|
|
||||||
|
> "TTY detection matters. Local testing (Claude Code 2.1.150) shows: in a real TTY, `claude` without `-p` enters the TUI and does not emit NDJSON. With piped stdin/stdout (`child_process.spawn`), it emits NDJSON even without `-p`. This means Anthropic could use `isTTY` as the billing signal, not the `-p` flag."
|
||||||
|
|
||||||
|
OLP cannot independently confirm or refute this prior to 2026-06-15 — Anthropic's billing pool signaling is not exposed on any observable surface until the billing split goes live. **Any OLP implementation that bets on Transport A (stdio pipe) without P0 confirmation risks burning real Agent SDK credit on every Anthropic request.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
**Option 3 — Wait for OCP ADR 0007 P0 experiment outcome, then port the validated approach.**
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- **Avoid duplicated P0 risk.** Both OCP and OLP would run the same experiment against the same Anthropic billing pool. OCP is already designed to run it; OLP riding their result is a free observation.
|
||||||
|
- **Lower decision-tree noise.** P0 has three outcomes (Transport A wins / Transport B wins / both fail). OLP's right answer differs per outcome; making the decision before the data is speculation.
|
||||||
|
- **No code is wasted.** OLP currently routes anthropic via `claude -p`, which works (just expensive post-June-15). The cost during the wait window is bounded by the Agent SDK $100/month credit + OLP's family-scale request volume.
|
||||||
|
|
||||||
|
What "wait" means concretely:
|
||||||
|
|
||||||
|
1. **No code change to `lib/providers/anthropic.mjs`** until OCP ADR 0007 transitions from Draft → Accepted (which requires P0 success per OCP ADR 0007 § "Status").
|
||||||
|
2. **No Phase 4 D-day scheduled for this scope** until that transition AND maintainer issues an explicit Phase 4 "go" naming this ADR.
|
||||||
|
3. **This placeholder ADR stays Draft** until either OCP P0 lands AND OLP decides to port, OR OCP P0 fails decisively AND OLP marks this ADR Rejected with a "shelved per upstream P0 failure" note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P0 outcome → OLP action decision tree
|
||||||
|
|
||||||
|
Recorded here so a future Phase 4 brief can act mechanically once OCP P0 lands.
|
||||||
|
|
||||||
|
```
|
||||||
|
OCP ADR 0007 P0 result
|
||||||
|
│
|
||||||
|
├─ Transport A (stdio pipe) confirmed interactive-billing
|
||||||
|
│ → OLP Option 1 OR Option 2 (see § 4); maintainer decides.
|
||||||
|
│ Likely Option 1: port the lib/interactive-pool.mjs +
|
||||||
|
│ billing-router.mjs pattern into lib/providers/anthropic.mjs.
|
||||||
|
│ Updates ADR 0006 to spell out the new spawn mode.
|
||||||
|
│
|
||||||
|
├─ Transport B (PTY) confirmed; Transport A fails
|
||||||
|
│ → OLP Option 1 with PTY adapter; node-pty dependency added
|
||||||
|
│ under engines-bump scrutiny (this triggers a separate prior
|
||||||
|
│ PR per ADR 0007 § 11-like discipline: native addon adds
|
||||||
|
│ CI matrix work).
|
||||||
|
│
|
||||||
|
├─ Both transports billed as programmatic
|
||||||
|
│ → OLP marks this ADR Rejected. Anthropic provider stays on
|
||||||
|
│ `claude -p`. Operational mitigation: family-scale users
|
||||||
|
│ either (a) accept the Agent SDK $100 cap, (b) bring their
|
||||||
|
│ own API key (BYOK env path is already there), or (c) shift
|
||||||
|
│ volume to other providers (Codex / Mistral) via OLP's
|
||||||
|
│ existing fallback chain.
|
||||||
|
│
|
||||||
|
└─ P0 results unobservable (billing signals not exposed)
|
||||||
|
→ Continue waiting. Re-evaluate one billing cycle (30 days)
|
||||||
|
post-2026-06-15. OLP Anthropic provider remains on `-p`
|
||||||
|
path during the wait; users see Agent SDK credit consumption
|
||||||
|
as the cost signal.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation lanes (to be selected when P0 lands)
|
||||||
|
|
||||||
|
This section is informational only. No lane is selected at placeholder time.
|
||||||
|
|
||||||
|
### Option 1 — OLP parallel implementation
|
||||||
|
|
||||||
|
OLP's `lib/providers/anthropic.mjs` reimplements OCP's interactive pool natively. Replaces the current `claude -p` spawn with a pool-managed warm process + adapter selected per P0 outcome.
|
||||||
|
|
||||||
|
- **Pros:** OLP self-contained; no runtime dependency on OCP being installed.
|
||||||
|
- **Cons:** Duplicates substantial logic (pool lifecycle, transport adapter, crash backoff DEGRADED, permission auto-response). Two codebases drift over time.
|
||||||
|
|
||||||
|
### Option 2 — OLP chains OCP as backend
|
||||||
|
|
||||||
|
OLP's `lib/providers/anthropic.mjs` invokes OCP (via its existing HTTP entry surface, or via a future direct-spawn API) rather than spawning `claude` directly. OLP becomes a multi-provider layer ON TOP OF OCP for the Anthropic provider; other providers (Codex, Mistral) continue to be direct.
|
||||||
|
|
||||||
|
- **Pros:** Zero duplication; OLP benefits from OCP's P0-validated work automatically. Architectural separation: OCP owns Claude execution, OLP owns multi-provider routing.
|
||||||
|
- **Cons:** OLP gains a runtime dependency on OCP being installed + running. Double caching (OCP cache + OLP cache; cache key composition needs to avoid stampede). OCP's HTTP shim is OpenAI-spec-compatible but adds an extra hop's latency. OCP failure modes propagate.
|
||||||
|
|
||||||
|
### Option 3 — Both (default to OCP backend if available, fallback to local pool)
|
||||||
|
|
||||||
|
A hybrid: OLP detects OCP installed locally, prefers chaining; otherwise falls back to the parallel implementation. Most defensive but most complex.
|
||||||
|
|
||||||
|
**Default at placeholder time:** Option 1 is the simpler ship if P0 transports prove out. Option 2 is the cleaner architecture but adds operational coupling. Maintainer decides at P0-resolution time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risk assessment (placeholder snapshot)
|
||||||
|
|
||||||
|
| Risk | Likelihood (now) | Impact | Mitigation pending P0 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| OLP forgets this ADR exists | Medium (multi-month wait) | High (would mean OLP misses the post-June-15 window) | This ADR + cc-rules memory `~/.cc-rules/memory/learnings/ocp_adr_0007_interactive_mode_pool.md` |
|
||||||
|
| OCP ADR 0007 P0 fails entirely | Medium | High for OLP Anthropic users (Agent SDK $100 cap binds) | OLP's multi-provider fallback (Codex/Mistral) already shipped as Phase 1 work; users have a path |
|
||||||
|
| OCP ADR 0007 ships incomplete (interim) | Medium | Medium (OLP can't reliably port) | Wait for OCP to mark Accepted; don't port from Draft |
|
||||||
|
| Anthropic policy changes mid-wait | Medium | Depends — could obsolete the whole approach | Re-read OCP ADR 0007 + this ADR before any Phase 4 anthropic work; no decisions on stale information |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Out of scope (explicitly NOT in this ADR)
|
||||||
|
|
||||||
|
- **Any code change.** This is a placeholder + decision-tree record only.
|
||||||
|
- **OCP P0 design.** That work is OCP's responsibility per OCP ADR 0007 § "Implementation phases".
|
||||||
|
- **A new OCP-OLP integration protocol.** If Option 2 is selected at P0-resolution time, the integration shape is a separate ADR.
|
||||||
|
- **Engines-bump for node-pty.** Only relevant if P0 picks Transport B and Option 1 is selected. Then it lands as a separate prior PR per the ADR 0007 § 11 pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 4 priority interaction
|
||||||
|
|
||||||
|
This ADR is recorded BEFORE Phase 4 implementation scope is finalized. Phase 4 currently lists (per OLP v0.3.0 CHANGELOG):
|
||||||
|
|
||||||
|
- Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral)
|
||||||
|
- Audit retention policies (ADR 0008 § 11 deferral)
|
||||||
|
- SQLite hybrid migration (ADR 0007 § 13 trigger)
|
||||||
|
- Provider-cost weights for spend trend (ADR 0008 § 11 deferral)
|
||||||
|
|
||||||
|
If OCP P0 succeeds, **the interactive-mode port likely jumps to the top of Phase 4** (highest user impact: keeps OLP Anthropic users on subscription billing). The other items remain Phase 4 but reorder downstream.
|
||||||
|
|
||||||
|
If OCP P0 fails, **this ADR is shelved** and Phase 4 ordering is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- OLP retains a documented anchor for the interactive-mode option without committing implementation effort prematurely.
|
||||||
|
- Future Phase 4 planning has a structured decision tree, not a vague "we should look at OCP someday".
|
||||||
|
- Cross-machine + cross-session memory (cc-rules) ensures the dependency is visible to any future session reading the OLP project context.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- During the wait window (now → P0 outcome ≥ 2026-07-15), OLP Anthropic users consume Agent SDK credit post-2026-06-15. Bounded by family-scale request volume but a real operational cost.
|
||||||
|
- Some risk that "wait" turns into "forget" if multiple unrelated Phase 4 priorities crowd the agenda. Mitigated by this ADR + the cc-rules memory pointer.
|
||||||
|
|
||||||
|
**Reversibility:**
|
||||||
|
|
||||||
|
- This is a placeholder. Either P0 result transitions it (Accepted → port, Rejected → shelve) cleanly. The placeholder itself doesn't lock OLP into anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authority citations
|
||||||
|
|
||||||
|
- **OCP ADR 0007** at `~/ocp/docs/adr/0007-interactive-mode-pool.md` (maintainer workstation). Project repo: `dtzp555-max/ocp`.
|
||||||
|
- **PR #101** to `dtzp555-max/ocp` — external contributor's tmux-based prototype that triggered the OCP ADR 0007 redesign.
|
||||||
|
- **Anthropic 2026-06-15 billing-split announcement** — see `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`.
|
||||||
|
- **OLP ADR 0001** (Project Founding) — establishes the original billing-split → multi-provider motivation.
|
||||||
|
- **OLP ADR 0006** (Provider Inclusion + Risk Tier Framework) — the anthropic plugin's tier classification + the surface this ADR would amend.
|
||||||
|
- **OLP ADR 0007** (Multi-Key Auth, Phase 2) — per-key audit + cache layer that must continue to work across any anthropic execution-mode change.
|
||||||
|
- **OLP ADR 0008** (Dashboard + Audit Query, Phase 3) — Dashboard per-provider fields must continue to populate.
|
||||||
|
- **CLAUDE.md `release_kit.phase_rolling_mode`** — current_phase: Phase 4 (post-v0.3.0); this ADR explicitly does NOT consume a Phase 4 D-day until P0 lands.
|
||||||
|
- **Standing autopilot grant** (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`) — Phase 4+ requires new authorization; this placeholder is a decision-tree pre-record, not Phase 4 implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status transitions (recorded for clarity)
|
||||||
|
|
||||||
|
- 2026-05-25 — Created as Draft (Placeholder). OCP ADR 0007 also Draft.
|
||||||
|
- _(future)_ — If OCP ADR 0007 → Accepted with a confirmed transport: this ADR moves to "Pending Phase 4 implementation D-day", maintainer decides Option 1 / 2 / 3 + lane.
|
||||||
|
- _(future)_ — If OCP ADR 0007 → Rejected: this ADR moves to "Shelved (upstream P0 failure)" with a note explaining the fallback (multi-provider routing already covers).
|
||||||
@@ -21,6 +21,8 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
|
|||||||
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
|
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
|
||||||
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. |
|
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. |
|
||||||
| [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. |
|
| [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. |
|
||||||
|
| [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). |
|
||||||
|
| [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. |
|
||||||
|
|
||||||
## When to write a new ADR
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* lib/audit-query.mjs — OLP audit ndjson aggregate query layer (Phase 3 / D49)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0008 § 4 (query API surface) + § 5 (rotation file naming) +
|
||||||
|
* § 3 (storage layout). Reads `~/.olp/logs/audit.ndjson` (live) +
|
||||||
|
* `audit-YYYY-MM-DD.ndjson` (rotated dailies) and returns aggregate
|
||||||
|
* summaries shaped for the Dashboard endpoints (D50).
|
||||||
|
*
|
||||||
|
* Query model (ADR 0008 Lane 2 = A): in-memory scan per request. O(N) where
|
||||||
|
* N = total lines in the date range. Family-scale acceptable; SQLite hybrid
|
||||||
|
* (ADR 0007 § 13) is the documented forward path when N+queries get slow.
|
||||||
|
*
|
||||||
|
* PII discipline (ADR 0007 § 8 + ADR 0008 § 4.3): event shape is hash + shape
|
||||||
|
* only — no message content, no response content, no raw tokens. This module
|
||||||
|
* MUST NOT introduce derived fields that reveal content. Every aggregate
|
||||||
|
* function asserts the input event has the expected shape but does NOT inspect
|
||||||
|
* or relay message bodies.
|
||||||
|
*
|
||||||
|
* What is NOT in this module (intentional split):
|
||||||
|
* - Daily rotation trigger (D52, lib/audit.mjs extension)
|
||||||
|
* - Server endpoints that consume these queries (D50, server.mjs)
|
||||||
|
* - Dashboard HTML / DOM render (D51, dashboard.html)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
|
const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
|
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
||||||
|
const ROTATED_FILE_PATTERN = /^audit-(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||||
|
|
||||||
|
// ── Path helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _resolveOlpHome(opts) {
|
||||||
|
if (opts?.olpHome) return opts.olpHome;
|
||||||
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||||
|
return DEFAULT_OLP_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logsDir(opts) {
|
||||||
|
return join(_resolveOlpHome(opts), 'logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the UTC-date string (YYYY-MM-DD) for an ISO-8601 timestamp.
|
||||||
|
*/
|
||||||
|
function _utcDateString(isoTs) {
|
||||||
|
if (typeof isoTs !== 'string' || isoTs.length < 10) return null;
|
||||||
|
return isoTs.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the UTC-date string for an epoch-ms.
|
||||||
|
*/
|
||||||
|
function _utcDateFromMs(ms) {
|
||||||
|
return new Date(ms).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive range of UTC date strings from startDate to endDate (both
|
||||||
|
* YYYY-MM-DD). Returns the list in ascending order. Safe for spans up to
|
||||||
|
* several years (no upper bound enforced — caller's responsibility).
|
||||||
|
*/
|
||||||
|
function _dateRange(startDate, endDate) {
|
||||||
|
const dates = [];
|
||||||
|
const cur = new Date(`${startDate}T00:00:00Z`);
|
||||||
|
const end = new Date(`${endDate}T00:00:00Z`);
|
||||||
|
while (cur <= end) {
|
||||||
|
dates.push(cur.toISOString().slice(0, 10));
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File enumeration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover audit files in the logs directory. Returns a Map from
|
||||||
|
* date-string ('YYYY-MM-DD' or 'live' for the un-rotated file) to absolute
|
||||||
|
* file path. The 'live' entry is `audit.ndjson` if present; date-string
|
||||||
|
* entries are the rotated daily files matching `audit-YYYY-MM-DD.ndjson`.
|
||||||
|
*
|
||||||
|
* Returns an empty Map if the logs directory does not exist or is empty.
|
||||||
|
* Caller responsible for date filtering.
|
||||||
|
*
|
||||||
|
* @param {object} [opts] - { olpHome }
|
||||||
|
* @returns {Map<string, string>} date-string → absolute file path
|
||||||
|
*/
|
||||||
|
export function discoverAuditFiles(opts = {}) {
|
||||||
|
const dir = _logsDir(opts);
|
||||||
|
const out = new Map();
|
||||||
|
if (!existsSync(dir)) return out;
|
||||||
|
let entries;
|
||||||
|
try { entries = readdirSync(dir); } catch { return out; }
|
||||||
|
for (const name of entries) {
|
||||||
|
if (name === LIVE_AUDIT_FILE) {
|
||||||
|
out.set('live', join(dir, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = ROTATED_FILE_PATTERN.exec(name);
|
||||||
|
if (m) {
|
||||||
|
out.set(m[1], join(dir, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Line-level read + parse ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single ndjson line. Returns the event object on success, or
|
||||||
|
* null on parse error. Caller logs warn for null returns.
|
||||||
|
*/
|
||||||
|
function _parseLine(line) {
|
||||||
|
if (!line) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (typeof obj !== 'object' || obj === null) return null;
|
||||||
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all events from a single file, skipping malformed lines.
|
||||||
|
* Logs warn (via logEvent override or console) for each malformed line so
|
||||||
|
* a corrupted day doesn't kill the query.
|
||||||
|
*
|
||||||
|
* @param {string} path
|
||||||
|
* @param {(level: string, event: string, data?: object) => void} [logEvent]
|
||||||
|
* @returns {Array<object>} parsed events
|
||||||
|
*/
|
||||||
|
function _readFileEvents(path, logEvent) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(path, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
// Re-throw read errors (EACCES, ENOENT during race) so the dashboard
|
||||||
|
// endpoint surfaces 500 with diagnostic per ADR 0008 § 4.4.
|
||||||
|
throw new Error(`audit_query_read_failed: ${path}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const events = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const ev = _parseLine(line);
|
||||||
|
if (ev === null) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
events.push(ev);
|
||||||
|
}
|
||||||
|
if (skipped > 0 && logEvent) {
|
||||||
|
logEvent('warn', 'audit_query_skip_malformed', { path, skipped });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate all audit events in [startMs, endMs). Walks the rotated daily
|
||||||
|
* files whose date overlaps the range + today's live audit.ndjson. Within
|
||||||
|
* each file, includes only events whose `ts` falls in the window.
|
||||||
|
*
|
||||||
|
* Per ADR 0008 § 4.2: window semantics are half-open [start, end).
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.startMs - epoch-ms inclusive lower bound
|
||||||
|
* @param {number} args.endMs - epoch-ms exclusive upper bound
|
||||||
|
* @param {string} [args.olpHome]
|
||||||
|
* @param {(level: string, event: string, data?: object) => void} [args.logEvent]
|
||||||
|
* @yields {object} parsed audit event
|
||||||
|
*/
|
||||||
|
export function* readAuditWindow({ startMs, endMs, olpHome, logEvent } = {}) {
|
||||||
|
if (typeof startMs !== 'number' || typeof endMs !== 'number') {
|
||||||
|
throw new Error('readAuditWindow: startMs and endMs (numbers) are required');
|
||||||
|
}
|
||||||
|
if (endMs <= startMs) return; // empty window
|
||||||
|
|
||||||
|
const files = discoverAuditFiles({ olpHome });
|
||||||
|
if (files.size === 0) return;
|
||||||
|
|
||||||
|
// Walk all dates in [startMs, endMs) plus the live file (today).
|
||||||
|
const startDate = _utcDateFromMs(startMs);
|
||||||
|
const endDate = _utcDateFromMs(endMs - 1); // endMs is exclusive
|
||||||
|
const dateList = _dateRange(startDate, endDate);
|
||||||
|
|
||||||
|
for (const date of dateList) {
|
||||||
|
const path = files.get(date);
|
||||||
|
if (!path) continue;
|
||||||
|
const events = _readFileEvents(path, logEvent);
|
||||||
|
for (const ev of events) {
|
||||||
|
const tsStr = ev.ts;
|
||||||
|
if (typeof tsStr !== 'string') continue;
|
||||||
|
const tsMs = Date.parse(tsStr);
|
||||||
|
if (Number.isNaN(tsMs)) continue;
|
||||||
|
if (tsMs >= startMs && tsMs < endMs) yield ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live file (today) — always check; date may overlap window's end.
|
||||||
|
const livePath = files.get('live');
|
||||||
|
if (livePath) {
|
||||||
|
const events = _readFileEvents(livePath, logEvent);
|
||||||
|
for (const ev of events) {
|
||||||
|
const tsStr = ev.ts;
|
||||||
|
if (typeof tsStr !== 'string') continue;
|
||||||
|
const tsMs = Date.parse(tsStr);
|
||||||
|
if (Number.isNaN(tsMs)) continue;
|
||||||
|
if (tsMs >= startMs && tsMs < endMs) yield ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate request shape over a rolling window ending at "now".
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* {
|
||||||
|
* window: { startMs, endMs },
|
||||||
|
* request_count, status_2xx, status_4xx, status_5xx,
|
||||||
|
* by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, fallback_count } },
|
||||||
|
* by_owner_tier: { owner: N, guest: N, anonymous: N },
|
||||||
|
* by_path: { '/v1/chat/completions': N, '/v1/models': N, ... },
|
||||||
|
* median_latency_ms, p95_latency_ms,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Per ADR 0008 § 4.1 + § 4.3 PII discipline: aggregates count + categorical
|
||||||
|
* breakdowns only, NEVER message content.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.windowMs - duration in ms; window = [now - windowMs, now)
|
||||||
|
* @param {string} [args.olpHome]
|
||||||
|
* @param {(level, event, data?) => void} [args.logEvent]
|
||||||
|
* @param {() => number} [args._nowFn] - injectable for testing
|
||||||
|
*/
|
||||||
|
export function aggregateRequests({ windowMs, olpHome, logEvent, _nowFn } = {}) {
|
||||||
|
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||||
|
throw new Error('aggregateRequests: windowMs (positive number) is required');
|
||||||
|
}
|
||||||
|
const now = (_nowFn ?? Date.now)();
|
||||||
|
const startMs = now - windowMs;
|
||||||
|
const endMs = now;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
window: { startMs, endMs },
|
||||||
|
request_count: 0,
|
||||||
|
status_2xx: 0,
|
||||||
|
status_4xx: 0,
|
||||||
|
status_5xx: 0,
|
||||||
|
by_provider: {},
|
||||||
|
by_owner_tier: { owner: 0, guest: 0, anonymous: 0 },
|
||||||
|
by_path: {},
|
||||||
|
median_latency_ms: 0,
|
||||||
|
p95_latency_ms: 0,
|
||||||
|
};
|
||||||
|
const latencies = [];
|
||||||
|
|
||||||
|
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||||
|
result.request_count++;
|
||||||
|
|
||||||
|
// Status code bucket
|
||||||
|
const sc = typeof ev.status_code === 'number' ? ev.status_code : 0;
|
||||||
|
if (sc >= 200 && sc < 300) result.status_2xx++;
|
||||||
|
else if (sc >= 400 && sc < 500) result.status_4xx++;
|
||||||
|
else if (sc >= 500) result.status_5xx++;
|
||||||
|
|
||||||
|
// By provider
|
||||||
|
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
|
||||||
|
const p = result.by_provider[ev.provider] ??= {
|
||||||
|
count: 0, cache_hit: 0, cache_miss: 0, cache_bypass: 0, fallback_count: 0,
|
||||||
|
};
|
||||||
|
p.count++;
|
||||||
|
if (ev.cache_status === 'hit') p.cache_hit++;
|
||||||
|
else if (ev.cache_status === 'miss') p.cache_miss++;
|
||||||
|
else if (ev.cache_status === 'bypass') p.cache_bypass++;
|
||||||
|
if (typeof ev.fallback_hops === 'number' && ev.fallback_hops > 0) p.fallback_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// By owner tier
|
||||||
|
if (ev.owner_tier === 'owner') result.by_owner_tier.owner++;
|
||||||
|
else if (ev.owner_tier === 'guest') result.by_owner_tier.guest++;
|
||||||
|
else result.by_owner_tier.anonymous++;
|
||||||
|
|
||||||
|
// By path
|
||||||
|
if (typeof ev.path === 'string' && ev.path.length > 0) {
|
||||||
|
result.by_path[ev.path] = (result.by_path[ev.path] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latency
|
||||||
|
if (typeof ev.latency_ms === 'number' && ev.latency_ms >= 0) {
|
||||||
|
latencies.push(ev.latency_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Median + p95 over sorted latencies
|
||||||
|
if (latencies.length > 0) {
|
||||||
|
latencies.sort((a, b) => a - b);
|
||||||
|
const midIdx = Math.floor(latencies.length / 2);
|
||||||
|
result.median_latency_ms = latencies.length % 2 === 0
|
||||||
|
? Math.round((latencies[midIdx - 1] + latencies[midIdx]) / 2)
|
||||||
|
: latencies[midIdx];
|
||||||
|
const p95Idx = Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95));
|
||||||
|
result.p95_latency_ms = latencies[p95Idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-N fallback chains by trigger count in window. A "chain" is the
|
||||||
|
* `tried_providers` array from an event with fallback_hops > 0. Returns
|
||||||
|
* sorted array descending by count; ties broken by earliest first_seen.
|
||||||
|
*
|
||||||
|
* [{ chain: ['anthropic', 'openai'], count: 42, first_seen, last_seen }, ...]
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.windowMs
|
||||||
|
* @param {number} [args.limit=10]
|
||||||
|
* @param {string} [args.olpHome]
|
||||||
|
* @param {(level, event, data?) => void} [args.logEvent]
|
||||||
|
* @param {() => number} [args._nowFn]
|
||||||
|
*/
|
||||||
|
export function topFallbackChains({ windowMs, limit = 10, olpHome, logEvent, _nowFn } = {}) {
|
||||||
|
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||||
|
throw new Error('topFallbackChains: windowMs (positive number) is required');
|
||||||
|
}
|
||||||
|
const now = (_nowFn ?? Date.now)();
|
||||||
|
const startMs = now - windowMs;
|
||||||
|
const endMs = now;
|
||||||
|
|
||||||
|
// Map chain-key (joined string) → aggregate
|
||||||
|
const chains = new Map();
|
||||||
|
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||||
|
if (typeof ev.fallback_hops !== 'number' || ev.fallback_hops <= 0) continue;
|
||||||
|
if (!Array.isArray(ev.tried_providers) || ev.tried_providers.length < 2) continue;
|
||||||
|
const key = ev.tried_providers.join('→');
|
||||||
|
const entry = chains.get(key);
|
||||||
|
const ts = typeof ev.ts === 'string' ? ev.ts : null;
|
||||||
|
if (entry === undefined) {
|
||||||
|
chains.set(key, {
|
||||||
|
chain: [...ev.tried_providers],
|
||||||
|
count: 1,
|
||||||
|
first_seen: ts,
|
||||||
|
last_seen: ts,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
entry.count++;
|
||||||
|
if (ts && (!entry.first_seen || ts < entry.first_seen)) entry.first_seen = ts;
|
||||||
|
if (ts && (!entry.last_seen || ts > entry.last_seen)) entry.last_seen = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort desc by count, ascending by first_seen on ties
|
||||||
|
const arr = [...chains.values()];
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
if (b.count !== a.count) return b.count - a.count;
|
||||||
|
if (a.first_seen && b.first_seen) return a.first_seen < b.first_seen ? -1 : a.first_seen > b.first_seen ? 1 : 0;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return arr.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily series of request_count + median latency_ms + by_provider over N
|
||||||
|
* UTC days ending today. Sparse-fills zero-request days. Returns ascending
|
||||||
|
* by date:
|
||||||
|
*
|
||||||
|
* [{ date: '2026-05-22', request_count, median_latency_ms, by_provider }, ...]
|
||||||
|
*
|
||||||
|
* by_provider is { [providerKey]: count } per day.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.days
|
||||||
|
* @param {string} [args.olpHome]
|
||||||
|
* @param {(level, event, data?) => void} [args.logEvent]
|
||||||
|
* @param {() => number} [args._nowFn]
|
||||||
|
*/
|
||||||
|
export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
|
||||||
|
if (typeof days !== 'number' || days <= 0) {
|
||||||
|
throw new Error('spendTrendDaily: days (positive number) is required');
|
||||||
|
}
|
||||||
|
const now = (_nowFn ?? Date.now)();
|
||||||
|
|
||||||
|
// Compute the N UTC dates ending today (inclusive). Semantics: "last N
|
||||||
|
// calendar dates ending today" — NOT "events within a rolling N*86400-ms
|
||||||
|
// window ago" (the latter would span N+1 distinct UTC dates and produce
|
||||||
|
// off-by-one buckets at non-midnight call times).
|
||||||
|
const dates = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
dates.push(_utcDateFromMs(now - i * 86400 * 1000));
|
||||||
|
}
|
||||||
|
// Window covers the start of the first date through "now" so readAuditWindow
|
||||||
|
// sees every event whose ts falls in any of the N dates' UTC days.
|
||||||
|
const startMs = Date.parse(`${dates[0]}T00:00:00Z`);
|
||||||
|
const endMs = now;
|
||||||
|
|
||||||
|
// Bucket by UTC date
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||||
|
const date = _utcDateString(ev.ts);
|
||||||
|
if (!date) continue;
|
||||||
|
const b = buckets.get(date) ?? { request_count: 0, latencies: [], by_provider: {} };
|
||||||
|
b.request_count++;
|
||||||
|
if (typeof ev.latency_ms === 'number') b.latencies.push(ev.latency_ms);
|
||||||
|
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
|
||||||
|
b.by_provider[ev.provider] = (b.by_provider[ev.provider] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
buckets.set(date, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparse-fill using the precomputed dates list (preserves ascending order)
|
||||||
|
return dates.map(date => {
|
||||||
|
const b = buckets.get(date);
|
||||||
|
if (b) {
|
||||||
|
b.latencies.sort((a, b) => a - b);
|
||||||
|
const midIdx = Math.floor(b.latencies.length / 2);
|
||||||
|
const median = b.latencies.length === 0 ? 0
|
||||||
|
: b.latencies.length % 2 === 0
|
||||||
|
? Math.round((b.latencies[midIdx - 1] + b.latencies[midIdx]) / 2)
|
||||||
|
: b.latencies[midIdx];
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
request_count: b.request_count,
|
||||||
|
median_latency_ms: median,
|
||||||
|
by_provider: b.by_provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { date, request_count: 0, median_latency_ms: 0, by_provider: {} };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-derived cache hit rate over the window. Differs from
|
||||||
|
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
||||||
|
* this is the audit-side rate scoped to the rolling window.
|
||||||
|
*
|
||||||
|
* { window: { startMs, endMs }, total, hit, miss, bypass, hit_rate, by_provider }
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.windowMs
|
||||||
|
* @param {string} [args.olpHome]
|
||||||
|
* @param {(level, event, data?) => void} [args.logEvent]
|
||||||
|
* @param {() => number} [args._nowFn]
|
||||||
|
*/
|
||||||
|
export function cacheHitRateWindow({ windowMs, olpHome, logEvent, _nowFn } = {}) {
|
||||||
|
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||||
|
throw new Error('cacheHitRateWindow: windowMs (positive number) is required');
|
||||||
|
}
|
||||||
|
const now = (_nowFn ?? Date.now)();
|
||||||
|
const startMs = now - windowMs;
|
||||||
|
const endMs = now;
|
||||||
|
|
||||||
|
let total = 0, hit = 0, miss = 0, bypass = 0;
|
||||||
|
const by_provider = {};
|
||||||
|
|
||||||
|
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||||
|
if (ev.cache_status === null || ev.cache_status === undefined) continue;
|
||||||
|
total++;
|
||||||
|
const p = typeof ev.provider === 'string' && ev.provider.length > 0 ? ev.provider : '__unknown__';
|
||||||
|
const pe = by_provider[p] ??= { total: 0, hit: 0, miss: 0, bypass: 0, hit_rate: 0 };
|
||||||
|
pe.total++;
|
||||||
|
if (ev.cache_status === 'hit') { hit++; pe.hit++; }
|
||||||
|
else if (ev.cache_status === 'miss') { miss++; pe.miss++; }
|
||||||
|
else if (ev.cache_status === 'bypass') { bypass++; pe.bypass++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hit_rate per provider + overall (excludes bypass from denominator
|
||||||
|
// since bypass-by-cache_control is intentional non-cacheable, not a cache miss).
|
||||||
|
for (const p of Object.values(by_provider)) {
|
||||||
|
const denom = p.hit + p.miss;
|
||||||
|
p.hit_rate = denom > 0 ? p.hit / denom : 0;
|
||||||
|
}
|
||||||
|
const overallDenom = hit + miss;
|
||||||
|
const hit_rate = overallDenom > 0 ? hit / overallDenom : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: { startMs, endMs },
|
||||||
|
total, hit, miss, bypass, hit_rate, by_provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
+174
-2
@@ -23,15 +23,23 @@
|
|||||||
* message content. Hash + shape only.
|
* message content. Hash + shape only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
import { appendFileSync, mkdirSync, chmodSync, renameSync, existsSync, statSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
const OLP_HOME_ENV = 'OLP_HOME';
|
const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
||||||
|
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
||||||
|
const ROTATED_FILE_PREFIX = 'audit-';
|
||||||
|
const ROTATED_FILE_SUFFIX = '.ndjson';
|
||||||
|
|
||||||
let _dropCounter = 0;
|
let _dropCounter = 0;
|
||||||
|
let _rotateCounter = 0; // observability + test assertion target
|
||||||
|
let _rotateFailCounter = 0;
|
||||||
|
// Module-cached "last UTC date we saw at append time" so we don't read
|
||||||
|
// disk metadata on every append just to check for rotation.
|
||||||
|
let _lastSeenUtcDate = _utcDateNow();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
||||||
@@ -44,6 +52,120 @@ function _resolveOlpHome(opts) {
|
|||||||
return DEFAULT_OLP_HOME;
|
return DEFAULT_OLP_HOME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current UTC date as YYYY-MM-DD. Module-private; reused by rotation logic.
|
||||||
|
*/
|
||||||
|
function _utcDateNow() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the first event's `ts` from the live audit file to discover the
|
||||||
|
* date it was opened on (for stale-cache recovery when the process is
|
||||||
|
* restarted and the in-memory `_lastSeenUtcDate` doesn't match). Returns
|
||||||
|
* the YYYY-MM-DD string, or null if the file is missing / empty /
|
||||||
|
* malformed.
|
||||||
|
*/
|
||||||
|
function _firstEventDateInLiveFile(livePath) {
|
||||||
|
try {
|
||||||
|
if (!existsSync(livePath)) return null;
|
||||||
|
// Read the file + slice off the first ndjson line. For audit ndjson the
|
||||||
|
// first line is at most a few hundred bytes; this is fine for the rare
|
||||||
|
// "process restart with a stale live file" recovery path. (Family-scale
|
||||||
|
// single-file audit is bounded; multi-MB scans are not a real concern
|
||||||
|
// until Phase 4+ when Option 3 SQLite migration would kick in anyway.)
|
||||||
|
const raw = readFileSync(livePath, 'utf-8');
|
||||||
|
const nl = raw.indexOf('\n');
|
||||||
|
const firstLine = nl === -1 ? raw : raw.slice(0, nl);
|
||||||
|
if (!firstLine) return null;
|
||||||
|
const obj = JSON.parse(firstLine);
|
||||||
|
if (typeof obj?.ts !== 'string') return null;
|
||||||
|
return obj.ts.slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the live audit file (if any) to its UTC-date suffix. Idempotent:
|
||||||
|
* if a rotated file with that name already exists (e.g., cron beat us to
|
||||||
|
* it), this skips the rename.
|
||||||
|
*
|
||||||
|
* Per ADR 0008 § 5.1 + § 5.3. SYNCHRONOUS so callers (appendAuditEvent +
|
||||||
|
* external cron) can rely on it completing before the next IO operation.
|
||||||
|
* Sync rotation eliminates the race that an async wrapper would create
|
||||||
|
* between the date-change-detection and the append (where the append could
|
||||||
|
* land in the old un-rotated file).
|
||||||
|
*
|
||||||
|
* Concurrent in-process invocations: Node's single-threaded event loop
|
||||||
|
* serializes synchronous calls within a tick. Cross-tick concurrency
|
||||||
|
* uses the `_lastSeenUtcDate` cache as the gate — once set, subsequent
|
||||||
|
* appends short-circuit the rotation check.
|
||||||
|
*
|
||||||
|
* @param {object} args - { olpHome, logEvent, _nowFn (test injection) }
|
||||||
|
* @returns {{ rotated: boolean, fromPath?: string, toPath?: string, dateUsed?: string }}
|
||||||
|
*/
|
||||||
|
export function _maybeRotateAudit(args = {}) {
|
||||||
|
const olpHome = _resolveOlpHome(args);
|
||||||
|
const logEvent = args.logEvent ?? ((level, ev, data) => {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||||
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||||
|
});
|
||||||
|
const nowFn = args._nowFn ?? (() => new Date());
|
||||||
|
|
||||||
|
const logsDir = join(olpHome, 'logs');
|
||||||
|
const livePath = join(logsDir, LIVE_AUDIT_FILE);
|
||||||
|
if (!existsSync(livePath)) {
|
||||||
|
// No live file yet (first append ever); no rotation needed.
|
||||||
|
_lastSeenUtcDate = nowFn().toISOString().slice(0, 10);
|
||||||
|
return { rotated: false };
|
||||||
|
}
|
||||||
|
// Determine the "date the live file holds" — use the first event's ts.
|
||||||
|
// Fall back to file mtime if events absent (corrupt / empty file edge).
|
||||||
|
let fileDate = _firstEventDateInLiveFile(livePath);
|
||||||
|
if (fileDate === null) {
|
||||||
|
try {
|
||||||
|
fileDate = statSync(livePath).mtime.toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return { rotated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const today = nowFn().toISOString().slice(0, 10);
|
||||||
|
if (fileDate === today) {
|
||||||
|
_lastSeenUtcDate = today;
|
||||||
|
return { rotated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate: rename live → audit-<fileDate>.ndjson.
|
||||||
|
const rotatedName = `${ROTATED_FILE_PREFIX}${fileDate}${ROTATED_FILE_SUFFIX}`;
|
||||||
|
const rotatedPath = join(logsDir, rotatedName);
|
||||||
|
if (existsSync(rotatedPath)) {
|
||||||
|
// Cron or another writer beat us; the live file holding fileDate's
|
||||||
|
// events must be merged manually. Per ADR 0008 § 5.3 we log + skip.
|
||||||
|
logEvent('warn', 'audit_rotate_target_exists', {
|
||||||
|
livePath, rotatedPath,
|
||||||
|
message: 'rotation target exists — concurrent rotator beat in-process check; manual merge required if events overlap',
|
||||||
|
});
|
||||||
|
_lastSeenUtcDate = today;
|
||||||
|
return { rotated: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
renameSync(livePath, rotatedPath);
|
||||||
|
_rotateCounter++;
|
||||||
|
_lastSeenUtcDate = today;
|
||||||
|
logEvent('info', 'audit_rotated', { fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate });
|
||||||
|
return { rotated: true, fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate };
|
||||||
|
} catch (err) {
|
||||||
|
_rotateFailCounter++;
|
||||||
|
logEvent('warn', 'audit_rotate_failed', {
|
||||||
|
livePath, rotatedPath,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
// Don't update _lastSeenUtcDate so the next append retries.
|
||||||
|
return { rotated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
||||||
*
|
*
|
||||||
@@ -60,13 +182,28 @@ function _resolveOlpHome(opts) {
|
|||||||
export function appendAuditEvent(event, opts = {}) {
|
export function appendAuditEvent(event, opts = {}) {
|
||||||
const olpHome = _resolveOlpHome(opts);
|
const olpHome = _resolveOlpHome(opts);
|
||||||
const logsDir = join(olpHome, 'logs');
|
const logsDir = join(olpHome, 'logs');
|
||||||
const path = join(logsDir, 'audit.ndjson');
|
const path = join(logsDir, LIVE_AUDIT_FILE);
|
||||||
const line = JSON.stringify(event) + '\n';
|
const line = JSON.stringify(event) + '\n';
|
||||||
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
||||||
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// D52: cheap fast-path date check. If the module-cached date matches the
|
||||||
|
// current UTC date, skip the rotation probe entirely (no disk I/O). If
|
||||||
|
// the date has changed, synchronously rotate BEFORE the append so the
|
||||||
|
// append lands in the (post-rotation) new live file rather than the
|
||||||
|
// about-to-rotate-away old one.
|
||||||
|
// Per ADR 0008 § 5.1: rotation fires "on the first append after a UTC
|
||||||
|
// date change." Synchronous rotation ensures no append straddles the
|
||||||
|
// boundary — old-date events land in the rotated file; new-date events
|
||||||
|
// land in the fresh live file. The cache flip happens INSIDE
|
||||||
|
// _maybeRotateAudit so concurrent in-process re-triggers short-circuit.
|
||||||
|
const today = _utcDateNow();
|
||||||
|
if (today !== _lastSeenUtcDate) {
|
||||||
|
_maybeRotateAudit({ olpHome, logEvent });
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||||
try {
|
try {
|
||||||
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||||
@@ -108,3 +245,38 @@ export function getAuditDropCount() {
|
|||||||
export function __resetAuditDropCount() {
|
export function __resetAuditDropCount() {
|
||||||
_dropCounter = 0;
|
_dropCounter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process count of successful rotations performed. Test + future
|
||||||
|
* observability surface.
|
||||||
|
*/
|
||||||
|
export function getAuditRotateCount() {
|
||||||
|
return _rotateCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process count of failed rotation attempts (rename threw, target
|
||||||
|
* existed, etc.). Test + future observability surface.
|
||||||
|
*/
|
||||||
|
export function getAuditRotateFailCount() {
|
||||||
|
return _rotateFailCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: reset the rotation counters + the in-process "last seen UTC
|
||||||
|
* date" cache so each test exercises a fresh code path.
|
||||||
|
*/
|
||||||
|
export function __resetAuditRotateState() {
|
||||||
|
_rotateCounter = 0;
|
||||||
|
_rotateFailCounter = 0;
|
||||||
|
_lastSeenUtcDate = _utcDateNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: force the cached "last seen UTC date" to a specific value
|
||||||
|
* so the next appendAuditEvent observes a date change and triggers
|
||||||
|
* rotation deterministically.
|
||||||
|
*/
|
||||||
|
export function __setLastSeenUtcDateForTesting(dateStr) {
|
||||||
|
_lastSeenUtcDate = dateStr;
|
||||||
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "olp",
|
"name": "olp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.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",
|
||||||
"bin": {
|
"bin": {
|
||||||
"olp-keys": "./bin/olp-keys.mjs"
|
"olp-keys": "./bin/olp-keys.mjs",
|
||||||
|
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node test-features.mjs",
|
"test": "node test-features.mjs",
|
||||||
"olp-keys": "node bin/olp-keys.mjs"
|
"olp-keys": "node bin/olp-keys.mjs",
|
||||||
|
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
+207
-3
@@ -57,6 +57,13 @@ import {
|
|||||||
ENV_OWNER_KEY_ID,
|
ENV_OWNER_KEY_ID,
|
||||||
} 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.
|
||||||
|
import {
|
||||||
|
aggregateRequests as auditAggregateRequests,
|
||||||
|
topFallbackChains as auditTopFallbackChains,
|
||||||
|
spendTrendDaily as auditSpendTrendDaily,
|
||||||
|
cacheHitRateWindow as auditCacheHitRateWindow,
|
||||||
|
} from './lib/audit-query.mjs';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -570,10 +577,17 @@ async function handleHealth(req, res) {
|
|||||||
const available = listAllProviderNames().length;
|
const available = listAllProviderNames().length;
|
||||||
const providerStatuses = {};
|
const providerStatuses = {};
|
||||||
for (const [name, provider] of loadedProviders) {
|
for (const [name, provider] of loadedProviders) {
|
||||||
|
// D56 / v1.x roadmap #4 (ADR 0002 Amendment 6 forward note): surface
|
||||||
|
// per-provider active spawn count for capacity-planning observability.
|
||||||
|
// D38 shipped getActiveSpawnCount; this is the /health integration.
|
||||||
|
// The field is set BEFORE healthCheck() in case healthCheck throws —
|
||||||
|
// activeSpawns is cheap (in-memory counter read) and useful even
|
||||||
|
// when healthCheck fails.
|
||||||
|
const activeSpawns = getActiveSpawnCount(name);
|
||||||
try {
|
try {
|
||||||
providerStatuses[name] = await provider.healthCheck();
|
providerStatuses[name] = { ...(await provider.healthCheck()), activeSpawns };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
providerStatuses[name] = { ok: false, error: e.message };
|
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendJSON(res, 200, {
|
sendJSON(res, 200, {
|
||||||
@@ -806,7 +820,15 @@ async function handleChatCompletions(req, res) {
|
|||||||
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
|
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
|
||||||
if (chain.length === 0) {
|
if (chain.length === 0) {
|
||||||
auditCtx.error_code = 'key_no_provider_access';
|
auditCtx.error_code = 'key_no_provider_access';
|
||||||
auditCtx.tried_providers = _originalChainProviders;
|
// D53 (D45 P2 deferral fix): tried_providers semantic per ADR 0007 § 8 is
|
||||||
|
// "providers the server actually dispatched a spawn against". On 403 the
|
||||||
|
// filtered chain is empty — no provider was dispatched, so the audit
|
||||||
|
// value is []. The configured-but-blocked chain providers go into the
|
||||||
|
// human-readable error message + a separate _diagnostic field we don't
|
||||||
|
// surface in audit (would distort downstream queries like "which
|
||||||
|
// providers did key X actually call"). ADR 0007 § 8 amended in this
|
||||||
|
// D-day's CHANGELOG entry to spell the semantic.
|
||||||
|
auditCtx.tried_providers = [];
|
||||||
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
|
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
|
||||||
return sendError(
|
return sendError(
|
||||||
res, 403,
|
res, 403,
|
||||||
@@ -1462,6 +1484,174 @@ async function handleChatCompletions(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 3 / D50 — Management endpoints (owner_only_block per ADR 0008 §8) ──
|
||||||
|
|
||||||
|
// Read dashboard.html once at startup; cached in memory thereafter. If the
|
||||||
|
// file is absent (e.g. test that imports server.mjs from a non-repo cwd), we
|
||||||
|
// still serve a minimal in-memory fallback. The file lives at the repo root.
|
||||||
|
let _dashboardHtmlCache = null;
|
||||||
|
function _loadDashboardHtml() {
|
||||||
|
if (_dashboardHtmlCache !== null) return _dashboardHtmlCache;
|
||||||
|
try {
|
||||||
|
const path = join(__dirname, 'dashboard.html');
|
||||||
|
_dashboardHtmlCache = readFileSync(path, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
_dashboardHtmlCache = '<!DOCTYPE html><title>OLP Dashboard</title><p>dashboard.html not on disk; server has fallen back to this in-memory stub. Check the OLP installation.</p>';
|
||||||
|
}
|
||||||
|
return _dashboardHtmlCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gated owner-only_block handler factory per ADR 0008 §8 + §7.5. Builds the
|
||||||
|
* audit ctx, runs auth, blocks non-owner with 401, wires res.on('finish') for
|
||||||
|
* audit + touchLastUsed, then calls inner(req, res, olpIdentity, auditCtx).
|
||||||
|
*
|
||||||
|
* inner should be async (or sync); errors propagate to the router's catch.
|
||||||
|
*/
|
||||||
|
async function _runOwnerOnlyManagementEndpoint(req, res, method, path, inner) {
|
||||||
|
const startMs = Date.now();
|
||||||
|
const auditCtx = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
key_id: ANONYMOUS_KEY_ID,
|
||||||
|
owner_tier: 'anonymous',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
status_code: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
cache_status: null,
|
||||||
|
fallback_hops: 0,
|
||||||
|
tried_providers: [],
|
||||||
|
error_code: null,
|
||||||
|
ir_request_hash: null,
|
||||||
|
chain_id: null,
|
||||||
|
};
|
||||||
|
let _authedKeyId = null;
|
||||||
|
res.on('finish', () => {
|
||||||
|
auditCtx.status_code = res.statusCode;
|
||||||
|
auditCtx.latency_ms = Date.now() - startMs;
|
||||||
|
try { appendAuditEvent(auditCtx); } catch { /* best-effort */ }
|
||||||
|
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID && _authedKeyId !== ENV_OWNER_KEY_ID) {
|
||||||
|
touchLastUsed(_authedKeyId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: authenticate.
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
auditCtx.error_code = authResult.code;
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
const olpIdentity = authResult.authContext;
|
||||||
|
auditCtx.key_id = olpIdentity.keyId;
|
||||||
|
auditCtx.owner_tier = olpIdentity.owner_tier;
|
||||||
|
_authedKeyId = olpIdentity.keyId;
|
||||||
|
|
||||||
|
// Step 2: owner-only_block. ADR 0008 §8 says management endpoints REJECT
|
||||||
|
// non-owner identities outright (vs /health's trim model). Anonymous +
|
||||||
|
// guest both produce 401 here.
|
||||||
|
if (olpIdentity.owner_tier !== 'owner') {
|
||||||
|
auditCtx.error_code = 'owner_required';
|
||||||
|
return sendError(res, 401,
|
||||||
|
'OLP owner-tier identity required for this management endpoint.',
|
||||||
|
'owner_required',
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: delegate.
|
||||||
|
return await inner(req, res, olpIdentity, auditCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard
|
||||||
|
* Serves dashboard.html to owner identities. Owner-only_block (per ADR 0008 §8).
|
||||||
|
* Cached in memory at startup; D51 ships the real multi-panel UI; D50 ships a
|
||||||
|
* placeholder.
|
||||||
|
*/
|
||||||
|
async function handleDashboard(req, res) {
|
||||||
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/dashboard',
|
||||||
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
const html = _loadDashboardHtml();
|
||||||
|
res2.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(html, 'utf-8'),
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res2.end(html);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v0/management/dashboard-data
|
||||||
|
* Full JSON aggregate per ADR 0008 § 7.2. The dashboard 30s poll consumes
|
||||||
|
* this. Owner-only_block.
|
||||||
|
*/
|
||||||
|
async function handleManagementDashboardData(req, res) {
|
||||||
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data',
|
||||||
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
// Quota panel: collect quotaStatus from each loaded provider; null on
|
||||||
|
// throw or null return → "unavailable" indicator.
|
||||||
|
const quota = [];
|
||||||
|
for (const [name, provider] of loadedProviders) {
|
||||||
|
try {
|
||||||
|
const q = await provider.quotaStatus(null);
|
||||||
|
quota.push({ provider: name, ...(q ?? {}), available: q?.available ?? null });
|
||||||
|
} catch (err) {
|
||||||
|
quota.push({ provider: name, error: err?.message ?? String(err), available: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_24H = 24 * 60 * 60 * 1000;
|
||||||
|
const payload = {
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }),
|
||||||
|
cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }),
|
||||||
|
quota,
|
||||||
|
spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }),
|
||||||
|
top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }),
|
||||||
|
cache_stats: cacheStore.stats(),
|
||||||
|
};
|
||||||
|
sendJSON(res2, 200, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v0/management/quota
|
||||||
|
* Per-provider quota snapshot only (subset of dashboard-data). Useful for
|
||||||
|
* scripted monitoring. Owner-only_block.
|
||||||
|
*/
|
||||||
|
async function handleManagementQuota(req, res) {
|
||||||
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota',
|
||||||
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
const quota = [];
|
||||||
|
for (const [name, provider] of loadedProviders) {
|
||||||
|
try {
|
||||||
|
const q = await provider.quotaStatus(null);
|
||||||
|
quota.push({ provider: name, ...(q ?? {}), available: q?.available ?? null });
|
||||||
|
} catch (err) {
|
||||||
|
quota.push({ provider: name, error: err?.message ?? String(err), available: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cache/stats
|
||||||
|
* Live in-memory CacheStore stats. Owner-only_block.
|
||||||
|
* Per ADR 0008 § 7.4: returns the current cacheStore.stats() shape
|
||||||
|
* ({ hits, misses, size, inflightCount }). Per-(provider, model) breakdown
|
||||||
|
* is a Phase 4+ amendment trigger.
|
||||||
|
*/
|
||||||
|
async function handleCacheStats(req, res) {
|
||||||
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/cache/stats',
|
||||||
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
sendJSON(res2, 200, { generated_at: new Date().toISOString(), ...cacheStore.stats() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request router ────────────────────────────────────────────────────────
|
// ── Request router ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1487,6 +1677,20 @@ async function router(req, res) {
|
|||||||
return await handleChatCompletions(req, res);
|
return await handleChatCompletions(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3 / D50 — management endpoints (owner-only_block per ADR 0008 § 8)
|
||||||
|
if (method === 'GET' && path === '/dashboard') {
|
||||||
|
return await handleDashboard(req, res);
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path === '/v0/management/dashboard-data') {
|
||||||
|
return await handleManagementDashboardData(req, res);
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path === '/v0/management/quota') {
|
||||||
|
return await handleManagementQuota(req, res);
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path === '/cache/stats') {
|
||||||
|
return await handleCacheStats(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
// 404 for any unrecognised route
|
// 404 for any unrecognised route
|
||||||
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+1138
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user