mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3716a19fd | ||
|
|
ee4d9459aa | ||
|
|
53afea47ca | ||
|
|
0bdecd1235 | ||
|
|
e69e908dae | ||
|
|
e6701ff698 | ||
|
|
0048481764 | ||
|
|
ba69a3c13b | ||
|
|
679e3b367d | ||
|
|
9b66326e72 | ||
|
|
1062e88e77 | ||
|
|
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/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 ...`).
|
||||
- `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).**
|
||||
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
|
||||
- `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.**
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
+354
-1
@@ -4,7 +4,360 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
## Unreleased
|
||||
|
||||
(empty — Phase 3 entries land here once Phase 3 opens)
|
||||
(empty — Phase 5 entries land here once Phase 5 opens)
|
||||
|
||||
## v0.4.1 — 2026-05-26
|
||||
|
||||
### Post-Phase-4 hotfix batch (D74) — maintainer-review findings
|
||||
|
||||
Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent review. Every finding was a real runtime bug that the per-D-day fresh-context opus reviewers all missed because they reviewed against spec text, not against the runtime contract (default `auth.allow_anonymous: false`, real `/health` payload shape, real `/cache/stats` payload shape, real `/v0/management/dashboard-data` payload shape). **Phase 4 lesson: future implementation D-days MUST include at least one test that boots the server with the default production config and exercises the new feature end-to-end** — not just stub-mocked codepaths.
|
||||
|
||||
- **[P1-1] `olp doctor` no longer false-negatives on auth-required `/health`.** `lib/doctor.mjs` now accepts an `authHeaders` option (threaded from `bin/olp.mjs` `cmdDoctor` via the existing `authHeaders()` chain) and passes it to the `server.running` + `server.version` probes. The `server.running` check now distinguishes 401/403 ("server up but bearer token missing/invalid — set `OLP_API_KEY`") from "server unreachable" — so the `kind` discriminator routes to a clean fix-auth path instead of `fix_server` when the operator just forgot to export the env var.
|
||||
- **[P1-2] `bin/olp-connect` validates token shape + shell-quotes rc writes.** New `validate_olp_token <key> <source>` helper enforces the `^olp_[A-Za-z0-9_-]{43}$` regex (per ADR 0007 § 3 token format) at all 3 input sites: `--key` arg, `/health.anonymousKey` server-advertised consumption, and the interactive prompt fallback. New `shell_quote <value>` helper wraps rc-file writes (`export OPENAI_BASE_URL=$(shell_quote ...)`) so even a hypothetical bypass of the validator can't inject shell metacharacters into a sourced rc. systemd `environment.d/olp.conf` write additionally rejects embedded newlines. Hostile or malformed keys can no longer persist as shell startup injection.
|
||||
- **[P2-3] `olp usage` + `olp cache` human formatter rewritten against the real payload shape.** `cmdUsage` previously read `body.usage_24h.requests` / `body.providers` / `body.top_fallback_chains` — all undefined under the actual server payload shape — so users saw "requests: ?" + missing per-provider quota + missing top-chains. Now reads `body.window_24h.request_count` / `body.cache_hit_24h.hit_rate` / `body.quota` / `body.top_fallback_chains_24h` per `server.mjs:2027` + `lib/audit-query.mjs`. `cmdCache` previously read `body.entries` / `body.bytes` / `body.maxBytes` (OCP-era field names). Now reads `body.size` / `body.inflightCount` per `CacheStore.stats()` and computes hit rate from `hits + misses`.
|
||||
- **[P2-4] `olp-plugin/` `fmtHealth` iterates `providers.status` correctly.** Previously walked `Object.entries(body.providers)` which surfaced `enabled` / `available` / `status` as pseudo-providers (chat output showed `🟢 status` instead of `🟢 anthropic`). Now extracts the real provider map from `body.providers.status` and renders enabled/available counts in a header line + per-provider names with `activeSpawns` when present. Falls back to flat `body.providers.*` for the older OCP shape (backwards compat).
|
||||
- **[P3-5] Stale v0.3.0-era doc strings updated.** README header status line + Implementation Status § now reflect v0.4.0 shipped + Phase 5 open. `server.mjs` startup banner no longer hardcodes "Phase 1 in progress" (now just lists version + provider count — derives accurate state from `VERSION` without future maintenance touch-ups).
|
||||
|
||||
**Phase 4 process learning recorded.** Per Iron Rule 第二律 (evidence over "should work"), every D-day review pass must include at least one runtime smoke against the default production config. The D-day reviewer rubric is updated implicitly — D74 Suite 36 tests pin the wire-contract shape so a future D-day refactoring server payloads can't silently re-break the CLI / plugin / docs.
|
||||
|
||||
- **Test count delta:** 696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36.
|
||||
- **Files touched:** `lib/doctor.mjs` (P1-1), `bin/olp.mjs` (P1-1 + P2-3), `bin/olp-connect` (P1-2), `olp-plugin/index.js` (P2-4), `server.mjs` (P3-5 banner), `README.md` (P3-5), `test-features.mjs` (Suite 36 regression), `package.json` (version), `CHANGELOG.md` (this entry).
|
||||
- **Authority:** maintainer independent review of `main` / `v0.4.0` / commit `ee4d945` (2026-05-26 session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump patch, tag, release before next push").
|
||||
|
||||
## v0.4.0 — 2026-05-26
|
||||
|
||||
### Phase 4 — Operator + Client UX (D60 → D73)
|
||||
|
||||
**Overview.** v0.4.0 closes Phase 4 — the "operator + client UX" track that grew OLP from "I built a multi-provider proxy" to "my family can use it without me holding their hand." 5 D-day groups (D60 → D73), ~13 D-days, all under standing-autopilot grant + per-D-day fresh-context opus reviewer per Iron Rule 10. The maintainer-triggered close PR lands all of it under one version tag.
|
||||
|
||||
**Test count: 623 (v0.3.2) → 696 (v0.4.0).** +73 tests across the Phase 4 arc.
|
||||
|
||||
**Strategic decision recorded:** Phase 4 explicitly DEFERS `/v1/messages` (Anthropic-shape entry surface) per ADR 0010 — re-open strictly gated on ADR 0009 P0 success AND maintainer-named family CC user. README posture: Claude Code listed as Not supported as an OLP client; recommended alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility because OpenAI tool schema is the multi-provider lingua franca).
|
||||
|
||||
**Phase 4 release_kit checklist**
|
||||
|
||||
- [x] All 5 D-day groups landed on main (D60 + D61-D63 + D64-D67 + D68-D70 + D71-D73)
|
||||
- [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 group + per-D-day P0/P1/P2 fold-ins where applicable
|
||||
- [x] CHANGELOG "Unreleased" promoted to "## v0.4.0 — 2026-05-26"
|
||||
- [x] `package.json` bumped 0.3.2 → 0.4.0
|
||||
- [x] `CLAUDE.md release_kit.phase_rolling_mode.current_phase` Phase 4 → Phase 5; `current_pre_release_identifier` `0.4.0-phase4` → `0.5.0-phase5`
|
||||
- [x] README § IDE Setup + § Telegram/Discord Usage + § Operator CLI surfaces (env var table extension)
|
||||
- [x] ADR 0010 (Phase 4 charter), ADR 0011 (anonymous-key deployment-context limits), ADR 0002 Amendment 7 (provider doctorChecks contract) all on disk
|
||||
- [ ] Tag pushed (next step in this PR's lifecycle)
|
||||
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push)
|
||||
|
||||
---
|
||||
|
||||
### D60 (PR #40) — Phase 4 charter (ADR 0010) + default port 3456 → 4567
|
||||
|
||||
Opens Phase 4. No functional code change beyond the default port value; substantive D-day work lands D61 onward.
|
||||
|
||||
- **Default `OLP_PORT` changed `3456 → 4567`.** OCP defaults to 3456; OLP and OCP can now co-host on the same machine without `OLP_PORT` env override. Tests use `port: 0` ephemeral — no test-surface impact.
|
||||
- **ADR 0010 (Phase 4 charter) ratified.** Records 5 D-day group scope + explicit DEFER of `/v1/messages` with re-open trigger.
|
||||
- **ADR 0001 + ADR 0008 amendments.** Port-conflict assumption struck-and-amended; § 6.6 default-port reference updated.
|
||||
- **README quick start + Environment Variables table + Migration from OCP § note** updated.
|
||||
|
||||
### D61-D63 (PR #41) — SSE heartbeat + recentErrors[20] + /v0/management/status
|
||||
|
||||
First substantive Phase 4 implementation. 3 D-days bundled per Iron Rule 11 IDR (shared observability surface).
|
||||
|
||||
- **SSE heartbeat** via `streaming.heartbeat_interval_ms` config (default `0` = disabled, matches OCP safe default). When enabled, streaming branch emits `: keepalive\n\n` SSE comment every interval during silent windows; resets on real chunk; cleans up on stream end/error/abort/disconnect. Eager-headers-post-spawn from day one (the OCP `db11105` lesson). `X-Accel-Buffering: no` centralized via new `SSE_DEFAULT_HEADERS` constant. Per-attached-client lifecycle (each tee output gets its own timer).
|
||||
- **`recentErrors[20]` ring buffer.** Module-scope bounded ring, populated from 5 server-side error paths. Filter: only `ProviderError` OR `statusCode >= 500` (401/403 brute-force noise excluded; D61-D63 reviewer P2-1 explicit-401/403-reject fold-in). Path sanitization via OCP `server.mjs:1395` port. In-memory only (per OCP precedent).
|
||||
- **`GET /v0/management/status` combined endpoint** (owner-only_block). Returns `{ ok, version, uptime_ms, uptime_human, started_at, providers, stats, recent_errors, generated_at }`. `_totalRequests` + `_activeRequests` module-scope counters with idempotent-decrement guard.
|
||||
- **Authority:** ADR 0010 § D61-D63; OCP `server.mjs:660-685` (startHeartbeat), `301, 354-358` (ring), `1151-1188` (/status), `1395` (path sanitization), commit `db11105` (eager-headers); ADR 0007 § 7 + ADR 0008 (owner-only_block pattern).
|
||||
- **Test count delta:** 623 → 636 (+13).
|
||||
|
||||
### D64-D67 (PR #42) — `olp` Node CLI + `olp doctor` framework + per-provider doctor checks + ADR 0002 Amendment 7
|
||||
|
||||
Second substantive Phase 4 implementation. 4 D-days bundled — CLI dispatches to doctor; doctor calls plugins via new contract method; ADR amendment authorizes the contract change.
|
||||
|
||||
- **`bin/olp.mjs` Node CLI** with 11 subcommands: `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor / help`. Node not bash (per ADR 0010 § Notes — bash's python3 JSON-parsing fragility avoided). Token resolution: `OLP_API_KEY` env → `OLP_OWNER_TOKEN` env → helpful 401 message (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5, not recoverable). Output: human-readable ANSI text by default, `--json` for scripting. Exit codes `0=ok / 1=usage / 2=network|HTTP / 3=auth`. Installed via `package.json bin.olp` so `npx olp <subcommand>` works.
|
||||
- **`lib/doctor.mjs` framework** with machine-readable `next_action.ai_executable[]` for AI-driven self-repair. Per check: `{ id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }`. Built-in checks: `server.running / server.version / config.exists / config.providers_enabled / config.chains_configured / auth.owner_key_exists / system.node_version`. Per-provider checks dynamically collected via the new `provider.doctorChecks()` contract method. `--json` output emits `{ checks, kind: noop|update|fix_oauth|fix_config|fresh_install|fix_server|fix_provider, next_action: { ai_executable, human_required, verify }, summary }`. `--check <id|category>` for tight repair-loop fast paths. Reviewer P2 fold-in: `_shellQuote()` helper hardens `ai_executable[]` against malicious `OLP_HOME` shell-metacharacter injection.
|
||||
- **Per-provider `doctorChecks()`** in anthropic / codex / mistral plugins: CLI-availability probe + auth-presence probe. Each fail returns `evidence.fix_commands` (for `ai_executable[]`) or `evidence.human_required`.
|
||||
- **ADR 0002 Amendment 7** adds OPTIONAL `provider.doctorChecks(): DoctorCheck[]` to the Provider contract — backwards compatible (plugins without it contribute no provider checks).
|
||||
- **`olp restart`** documented caveat (reviewer P2-2): `launchctl kickstart -k` does NOT re-read plist `EnvironmentVariables`; bootout/bootstrap dance noted for env reloads.
|
||||
- **Authority:** ADR 0010 § D64-D67; ADR 0002 Amendment 7 (new); OCP `ocp` bash wrapper + `scripts/doctor.mjs` (port references); 2026-05-26 brainstorm Top 5 inheritance candidate #2.
|
||||
- **Test count delta:** 636 → 658 (+22).
|
||||
|
||||
### D68-D70 (PR #43) — `bin/olp-connect` + `/health.anonymousKey` + ADR 0011
|
||||
|
||||
Third substantive Phase 4 implementation. 3 D-days bundled — olp-connect consumes /health.anonymousKey; both governed by ADR 0011 trusted-LAN invariant.
|
||||
|
||||
- **`bin/olp-connect <host-ip>` (bash, 564 lines)** zero-config LAN client setup. Bash over Node so client machines without recent Node still work. Auto-detects 6 IDEs and configures each: Claude Code (detect + warn — NOT supported per ADR 0010), Cline (print VSCode-settings snippet — manual), Continue.dev (write idempotent `models:` entry to `~/.continue/config.yaml`), Cursor (snippet + WARNING about known base-URL fragility), Aider (write `OPENAI_API_BASE` + `OPENAI_API_KEY` to rc files), OpenClaw (detect + point at `olp-plugin/`). macOS `launchctl setenv` / Linux `~/.config/environment.d/olp.conf` for GUI-app env inheritance. `--dry-run` exercises every state-change site without modifying anything. Idempotent rc-file writes via bracketed `# OLP LAN ... # /OLP LAN` block.
|
||||
- **`/health.anonymousKey` opt-in field** + `auth.advertise_anonymous_key` config. Field appears in both trimmed AND full `/health` payloads when ALL THREE prerequisites hold: `auth.advertise_anonymous_key: true` + `auth.allow_anonymous: true` + at least one non-revoked guest-tier key has `plaintext_advertise` set. Default off — field ABSENT (not null), preserves v0.3.x `/health` shape. Three-prereq gate is graceful-degrade (server warns + boots; request-time re-checks).
|
||||
- **`bin/olp-keys keygen --anonymous --advertise`** new flag. Writes plaintext into manifest `plaintext_advertise` field AND prints WARNING + ADR 0011 pointer. Owner-tier rejected at BOTH CLI and lib layers (defense-in-depth). Reviewer P2-1 fold-in: `listKeys()` strips `plaintext_advertise` alongside `token_hash` — callers wanting the advertised plaintext for the `/health` publication path MUST go through `findAdvertisedKey()` (the only sanctioned read site).
|
||||
- **ADR 0011 (anonymous-key deployment-context)** new ADR codifying the trusted-LAN-only invariant. Threat model explicit; deployment-context table concrete; soft enforcement via startup warn if `BIND_ADDRESS` resolves to public IP AND `advertise_anonymous_key: true`. No hard allowlist (TLS-fronted private networks indistinguishable from public from server's perspective). Re-evaluation triggers named (Cloudflare Tunnel guidance / Phase 5 multi-tenant).
|
||||
- **Authority:** ADR 0010 § D68-D70; ADR 0011 (new); ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity classes) + § 9 (keygen flow); OCP `ocp-connect` (port reference); 2026-05-26 brainstorm Top 5 inheritance candidate #3.
|
||||
- **Test count delta:** 658 → 672 (+14).
|
||||
|
||||
### D71-D73 (PR #44) — `olp-plugin/` (OpenClaw /olp Telegram+Discord) + `docs/integrations/*.md` + README cross-refs
|
||||
|
||||
Final Phase 4 substantive D-day group. 3 D-days bundled — plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together.
|
||||
|
||||
- **`olp-plugin/` OpenClaw gateway plugin** (482 lines). Port of OCP `ocp-plugin/index.js` minus mutations. Subcommand parity with `olp` CLI: `/olp status / usage / cache` (owner-only) + `/olp health / models / providers / chain show / doctor / help` (informational). **Explicitly NOT ported** for security: `/olp keys keygen` (chat = brute-force-prone), `/olp keys revoke` (mutation), `/olp restart` (misclick risk), `/olp logs` (PII risk). Port resolution: `OLP_PROXY_URL` env → `OLP_PORT` env → plugin config `proxyUrl` → `http://127.0.0.1:4567` (D60 default). Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for 4096-char Telegram limit. No npm deps (OpenClaw provides Telegram/Discord transport).
|
||||
- **`docs/integrations/*.md` bundle** (6 pages + index). Per-IDE setup docs with status icons: Continue.dev ✅, Cline ✅ (cites Cline issue #7128 base-URL UI bug), Cursor ⚠️ (documented base-URL fragility), Aider ✅, **Claude Code ❌** (Anthropic wire format only; recommended alternative "Cline + OLP" per ADR 0010 § /v1/messages defer), OpenClaw ✅. Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. `docs/integrations/README.md` is the index.
|
||||
- **README updates.** New § "IDE Setup" linking `docs/integrations/README.md`. New § "Telegram / Discord Usage" with install + configure + restart + use. Quick Start mentions `olp-connect <ip>` as family-onboarding command. `package.json files` field extended to include `olp-plugin/` so the published tarball ships it.
|
||||
- **Authority:** ADR 0010 § D71-D73; OCP `ocp-plugin/index.js` (port reference); 2026-05-26 brainstorm prior-art survey IDE-specific quirks.
|
||||
- **Test count delta:** 672 → 696 (+24).
|
||||
|
||||
---
|
||||
|
||||
**Phase 4 close authority chain:** ADR 0010 (charter); CLAUDE.md `release_kit.phase_rolling_mode` (close trigger = explicit maintainer action — fired by maintainer 2026-05-26); standing autopilot grant covering D-day-by-D-day execution; 5 fresh-context opus reviewer passes (one per D-day group); 696/696 tests pass on this release commit head.
|
||||
|
||||
## v0.3.2 — 2026-05-25
|
||||
|
||||
### Post-Phase-3 cleanup batch #2 — streaming-path singleflight + TOCTOU close (D57 + D58 + D59)
|
||||
|
||||
Patch release closing v1.x roadmap #1 end-to-end. The cache layer's D4 singleflight (one spawn per identical concurrent request) was fully wired on the buffered path since v0.1 but NOT on the streaming path — N concurrent identical streaming requests each spawned their own CLI process. v0.3.2 ships the streaming sibling: tee fan-out, late-joiner replay, per-client backpressure, AbortController propagation, and TOCTOU close. 3 D-day commits (D57 + D58 + D59); ADR 0005 Amendment 8 §§1–14 implemented.
|
||||
|
||||
- **D57** (PR #36) — **cache layer.** New `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts) → { stream, isFirst, role }` mirroring `getOrCompute` on the streaming side. Internals: `_streamingInflight: Map<compositeKey, StreamingInflightEntry>` (composite key `keyId + '\0' + cacheKey`) with synchronous check+insert atomicity (closes TOCTOU per ADR 0005 Amendment 8 §1, §6); single-reader tee fan-out across all attached clients; late-joiner replay buffer (synchronous drain on attach; `STREAM_BACKPRESSURE` terminator if drain or replay-truncation would corrupt); per-client backpressure (`PER_CLIENT_QUEUE_CAP = 1 MB`, overridable via opts); accumulated replay cap (`ACCUMULATED_REPLAY_CAP = 10 MB`, mirrors D23 cache-entry cap); AbortController fires source-iterator return when all clients disconnect. New `'STREAM_BACKPRESSURE'` entry in `PROVIDER_ERROR_CODES` — NOT a hard trigger (whitelist-only `HARD_TRIGGER_CODES`). Suite 27 = 12 unit tests.
|
||||
- **D58** (PR #37) — **server wiring.** Streaming branch in `server.mjs` swapped from the peek+spawn pattern to `cacheStore.getOrComputeStreaming(...)`. `tryAcquireSpawn`/`releaseSpawn` moved INSIDE the `sourceFactory` closure per ADR 0005 Amendment 8 §7 (only the first caller acquires; attached joiners share the slot; release fires exactly once on source completion/error/abort). `CONCURRENCY_LIMIT` thrown by the factory triggers fallthrough to the buffered path (preserves today's behaviour). New `X-OLP-Streaming-Inflight: source | attached` HTTP header annotates per-response role (§11). New `cache_status: 'streaming_attached'` audit value tracks the singleflight win. `lib/audit-query.mjs` aggregate APIs (`aggregateRequests`, `cacheHitRateWindow`) extended with `cache_streaming_attached` / `streaming_attached` fields so the cache_status breakdown reconciles. `res.on('close')` propagates client disconnect into the tee's `attachedClients` accounting (§9). D16 truncated-not-cached invariant preserved via server-layer `cacheStore.delete` on stop-less exhaustion (the cache layer is IR-agnostic and writes accumulatedChunks on any source exhaustion; the IR-aware server deletes the entry when the source returned without a `{type:'stop'}` chunk). Suite 28 = 8 HTTP integration tests.
|
||||
- **D59** (PR #38) — **docs polish.** README § Known limitations bullet inverted to ✅ shipped marker. `docs/v1x-roadmap.md` #1 rewritten to closed state with 3-D-day breakdown. #6 (streaming SPAWN_FAILED salvage) unbundled from #1 because the tee architecture as implemented does not carry salvage semantics. Issue #16 closed with refs to PRs #36 / #37 / #38.
|
||||
- **Test count:** 603 (v0.3.1) → 623 (v0.3.2). +20 streaming-SF tests (Suite 27 = 12 unit, Suite 28 = 8 HTTP integration).
|
||||
- **Deferred sub-items (not blocking #1 closure):** (a) `X-OLP-Streaming-Inflight: solo` wire value not emitted — observable only post-stream via `streaming_inflight_source_done` log event's `attached_count: 0`. Future ADR amendment may expose via HTTP trailer. (b) `streaming_inflight_join` log event not emitted from the cache-layer `_attachClient` path because provider/model context lives in the sourceFactory closure (server-layer concern). (c) `isFirst` field returned by `getOrComputeStreaming` is unused by server.mjs (`role` supersedes); could be removed in a future cache-layer API cleanup.
|
||||
- **Authority:** ADR 0005 Amendment 8 (design ratified at D42 2026-05-25; implementation gated on maintainer "go" — fired 2026-05-25 post-v0.3.1). `docs/v1x-roadmap.md` #1 (closed). GitHub issue #16 (closed). ADR 0002 Amendment 6 (D38 `tryAcquireSpawn`/`releaseSpawn` semantics, now invoked from sourceFactory closure).
|
||||
|
||||
**Patch-release classification.** Per `release_kit.phase_rolling_mode` cross-Phase discipline + maintainer release-cut decision (this session, 2026-05-25): the new wire surface (`X-OLP-Streaming-Inflight` header + `streaming_attached` cache_status) is semver-wise a minor bump, but this is roadmap-cleanup work — NOT Phase 4 product scope. The reserved `0.4.0` identifier stays for the formal Phase 4 close. v0.3.2 ships as a patch under the Phase 4 pre-release banner. Tag push triggers `release.yml`.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ release_kit:
|
||||
# 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
|
||||
# before filing a compliance finding.
|
||||
current_phase: Phase 3
|
||||
current_pre_release_identifier: "0.3.0-phase3"
|
||||
current_phase: Phase 5
|
||||
current_pre_release_identifier: "0.5.0-phase5"
|
||||
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.
|
||||
|
||||
> **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.4.0 shipped (2026-05-26) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI (v0.2.0) + Phase 3 Dashboard + audit query layer + daily audit rotation (v0.3.0) + Phase 4 Operator + Client UX (v0.4.0): SSE heartbeat / `olp` Node CLI + `olp doctor` framework / `olp-connect` zero-config LAN setup / `/health.anonymousKey` opt-in / `/olp` Telegram-Discord plugin / 6-IDE integration docs. Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope: `/v1/messages` (gated on ADR 0009 P0 outcome + named family CC user), context-window-exceeded fallback trigger, per-(provider, model) live stats. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
||||
|
||||
---
|
||||
|
||||
@@ -31,12 +31,26 @@ npm install -g @dtzp555-max/olp
|
||||
# run setup (writes ~/.olp/config.json, asks which providers to enable)
|
||||
olp setup
|
||||
|
||||
# start the proxy (default port 3456 — same as OCP if you migrate)
|
||||
# start the proxy (default port 4567 since v0.4.0 — moved off OCP's 3456 so
|
||||
# OLP and OCP can co-host on the same machine. Set OLP_PORT=3456 if you have
|
||||
# no OCP on the machine and want the old default.)
|
||||
olp start
|
||||
|
||||
# point your IDE at http://localhost:3456/v1/chat/completions with the OLP API key from `olp keys list`.
|
||||
# point your IDE at http://localhost:4567/v1/chat/completions with the OLP API key from `olp keys list`.
|
||||
```
|
||||
|
||||
**Family-on-LAN onboarding (D68-D70).** For other devices on the same network, run on the client device:
|
||||
|
||||
```bash
|
||||
# Detects Cline / Continue.dev / Cursor / Aider / OpenClaw installed locally
|
||||
# and writes per-tool config pointing at the OLP host. Requires `python3`.
|
||||
olp-connect <olp-host-ip>
|
||||
```
|
||||
|
||||
If the OLP host has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, `olp-connect` picks up the token from `/health.anonymousKey` — zero out-of-band token paste required. See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
|
||||
|
||||
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Supported Providers
|
||||
@@ -94,16 +108,15 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
|
||||
|
||||
## API Endpoints
|
||||
|
||||
_placeholder — full table lands as each endpoint lands._
|
||||
|
||||
| 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/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot (owner-only). |
|
||||
| `/cache/stats` | GET | 5 | 📋 Planned | Cache hit rate, by-provider breakdown. |
|
||||
| `/v0/management/quota` | GET | 6 | 📋 Planned | Per-provider quota / credit pool status (best-effort). |
|
||||
| `/dashboard` | GET | 6 | 📋 Planned | Owner-only dashboard (localhost-bound by default). |
|
||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
|
||||
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
||||
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. |
|
||||
| `/v0/management/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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -113,11 +126,26 @@ _placeholder — full table lands per-phase as variables are introduced._
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OLP_PORT` | `3456` | HTTP listener port. |
|
||||
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
|
||||
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
||||
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
||||
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
||||
|
||||
### `config.json` keys introduced at Phase 4
|
||||
|
||||
These live in `~/.olp/config.json` (not env vars) — they're documented here alongside the env-var table for discoverability.
|
||||
|
||||
| Config key | Default | Description |
|
||||
|---|---|---|
|
||||
| `streaming.heartbeat_interval_ms` | `0` (disabled) | D61 / Phase 4. SSE keepalive comment frames during stream-silent windows. Set `>0` (e.g. `15000` for 15s) when OLP runs behind nginx / Cloudflare / Tailscale Funnel with idle-abort timeouts. |
|
||||
| `auth.advertise_anonymous_key` | `false` | D69 / Phase 4. When `true`, surfaces an existing guest-tier key's plaintext via `/health.anonymousKey` so `olp-connect <ip>` can self-bootstrap clients on the LAN with zero out-of-band coordination. **Requires `auth.allow_anonymous: true` AND at least one key created via `olp-keys keygen --anonymous --advertise`.** Trusted-LAN-only — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md). |
|
||||
|
||||
### Operator CLI surfaces (Phase 4)
|
||||
|
||||
- `olp` (Node CLI at `bin/olp.mjs`): `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor`. Run `npx olp --help` for full subcommand reference. `olp doctor --json` emits a machine-readable `next_action.ai_executable[]` payload designed for AI agents to self-repair OLP. See [ADR 0010](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md) § Phase 4 D-day plan and [ADR 0002 Amendment 7](./docs/adr/0002-plugin-architecture.md) (per-plugin `doctorChecks()` contract).
|
||||
- `olp-connect` (bash at `bin/olp-connect`): zero-config LAN client setup — detects Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw and configures each. Run `bash bin/olp-connect --help`. Requires `python3` for JSON parsing.
|
||||
- `olp-keys keygen --anonymous --advertise`: creates a guest-tier key with the plaintext stored alongside its hash so `/health.anonymousKey` can publish it. Prints an explicit ADR-0011 warning at keygen time.
|
||||
|
||||
### Per-provider auth env vars
|
||||
|
||||
These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests.
|
||||
@@ -165,9 +193,68 @@ 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)
|
||||
## IDE Setup
|
||||
|
||||
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.
|
||||
Per-tool setup pages live under [`docs/integrations/`](./docs/integrations/README.md). Index:
|
||||
|
||||
| Tool | Status | Notes |
|
||||
|---|---|---|
|
||||
| [Continue.dev](./docs/integrations/continue.md) | ✅ Supported | `config.yaml` `apiBase` (not `baseURL`); supports OLP custom headers |
|
||||
| [Cline](./docs/integrations/cline.md) | ✅ Supported | "OpenAI Compatible" provider; watch Cline issue [#7128](https://github.com/cline/cline/issues/7128) |
|
||||
| [Cursor](./docs/integrations/cursor.md) | ⚠️ Best-effort | "Override OpenAI Base URL" — known fragile across Cursor updates |
|
||||
| [Aider](./docs/integrations/aider.md) | ✅ Supported | `OPENAI_API_BASE` env + `openai/` model prefix; no custom-header support |
|
||||
| [Claude Code](./docs/integrations/claude-code.md) | ❌ Not supported | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline + OLP instead |
|
||||
| [OpenClaw](./docs/integrations/openclaw.md) | ✅ Supported | Telegram + Discord gateway via [`olp-plugin/`](./olp-plugin/) |
|
||||
|
||||
The fastest path is `olp-connect <olp-host-ip>` on the client device — it auto-detects what's installed and writes the per-tool config. See [Quick Start](#quick-start).
|
||||
|
||||
---
|
||||
|
||||
## Telegram / Discord Usage
|
||||
|
||||
OLP ships [`olp-plugin/`](./olp-plugin/) as a native OpenClaw gateway plugin. After install, family members get a read-only `/olp` slash command on whichever chat surfaces OpenClaw exposes (Telegram + Discord today).
|
||||
|
||||
**Install:**
|
||||
|
||||
```bash
|
||||
# Option A — OpenClaw CLI
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
|
||||
# Option B — symlink (equivalent)
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
**Configure:** edit `~/.openclaw/openclaw.json` and set the plugin's `apiKey` to an owner-tier OLP token created with:
|
||||
|
||||
```bash
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
```
|
||||
|
||||
Use a dedicated bot key — not the maintainer's personal owner key — so revocation is scoped.
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Restart:** `openclaw gateway restart`.
|
||||
|
||||
**Use:** `/olp status`, `/olp usage`, `/olp models`, `/olp health`, `/olp cache`, `/olp providers`, `/olp doctor`, `/olp help`.
|
||||
|
||||
**Read-only by design.** Mutating subcommands (`keygen`, `revoke`, `restart`, `logs`) are deliberately NOT exposed via chat — those are SSH-only via the local `olp` CLI. See [`olp-plugin/README.md`](./olp-plugin/README.md#what-you-can-not-do-from-chat-by-design) for the rationale.
|
||||
|
||||
---
|
||||
|
||||
## Implementation status (as of 2026-05-26, post-v0.4.0)
|
||||
|
||||
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||
|
||||
| File / artifact | Status | Notes |
|
||||
|---|---|---|
|
||||
@@ -184,8 +271,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`) |
|
||||
| `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/`. |
|
||||
| `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. |
|
||||
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
|
||||
| `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`. |
|
||||
| `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/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) |
|
||||
@@ -196,9 +285,13 @@ Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phas
|
||||
|
||||
Behaviors that work correctly at personal/family scale but have ratified follow-ups for a v1.x sprint. Single landing page: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md).
|
||||
|
||||
- **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 ✅ shipped (D57 + D58, 2026-05-25).** `cacheStore.getOrComputeStreaming(...)` mirrors the buffered-path `getOrCompute` and resolves the TOCTOU window between peek and spawn ([issue #16](https://github.com/dtzp555-max/olp/issues/16)). Two concurrent identical streaming requests share one CLI spawn via tee fan-out; late joiners receive accumulated replay + the live tail; per-client backpressure (`PER_CLIENT_QUEUE_CAP=1MB`) protects against slow consumers; full-disconnect aborts the source CLI via AbortController. New `X-OLP-Streaming-Inflight: source | attached` header annotates the role. New `cache_status: 'streaming_attached'` audit value tracks the singleflight win. Authority: [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md), v1.x roadmap #1.
|
||||
- **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 at v0.3.0 (D48–D54).** `docs/adr/0008-dashboard-and-audit-query.md` + `lib/audit-query.mjs` (D49) + 4 owner-only_block endpoints (D50) + `dashboard.html` (D51) + daily audit rotation (D52) + `tried_providers` schema fix (D53). All 15 ADR 0008 § 10 acceptance criteria covered.
|
||||
|
||||
- **Phase 4 (Operator + Client UX) shipped at v0.4.0 (D60 → D73).** ADR 0010 (charter) + ADR 0011 (anonymous-key trusted-LAN limits) + ADR 0002 Amendment 7 (provider `doctorChecks()` contract). Default `OLP_PORT` 3456 → 4567 so OLP and OCP can co-host. SSE heartbeat (D61) + `recentErrors[20]` + `/v0/management/status` (D62-D63). `bin/olp.mjs` Node CLI + `bin/olp-keys.mjs` + `lib/doctor.mjs` framework with `next_action.ai_executable[]` (D64-D67). `bin/olp-connect` bash zero-config IDE auto-config + opt-in `/health.anonymousKey` (D68-D70). `olp-plugin/` OpenClaw `/olp` Telegram-Discord plugin (read-only, no chat mutations) + 6 IDE integration docs at `docs/integrations/*.md` (D71-D73). Test count 623 → 696.
|
||||
|
||||
**Bootstrap workflow (D47):** for first-run / production setup:
|
||||
|
||||
@@ -213,7 +306,7 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
||||
npm start
|
||||
|
||||
# 4. Validate the key works (substitute the captured plaintext token)
|
||||
curl -H "Authorization: Bearer olp_..." http://localhost:3456/health
|
||||
curl -H "Authorization: Bearer olp_..." http://localhost:4567/health
|
||||
```
|
||||
|
||||
**Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once).
|
||||
@@ -249,8 +342,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 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 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 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 3** — Dashboard + audit query layer + daily audit rotation per ADR 0008: in-memory ndjson aggregate query layer (`lib/audit-query.mjs`), 4 owner-only_block management endpoints (`/dashboard` + `/v0/management/dashboard-data` + `/v0/management/quota` + `/cache/stats`), multi-panel `dashboard.html` with 30s poll, synchronous daily audit rotation + `bin/olp-audit-rotate.mjs` cron tool, `tried_providers` schema fix (D45 P2 deferral). ✅ Shipped — v0.3.0 (2026-05-25, D48 → D54). All 15 ADR 0008 § 10 acceptance criteria covered.
|
||||
- **Phase 4 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend.
|
||||
- **Phase 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.
|
||||
|
||||
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`.
|
||||
@@ -266,7 +360,7 @@ Anticipated user-facing flow (target: <5 minutes):
|
||||
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
|
||||
2. Install OLP.
|
||||
3. Run `olp migrate-from-ocp` — copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable.
|
||||
4. Start OLP. Clients pointing at port 3456 keep working; their existing OLP API keys remain valid.
|
||||
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid. **Note (v0.4.0+):** default port moved from 3456 → 4567 so OCP and OLP can co-host during migration; set `OLP_PORT=3456` if you want the pre-D60 default.
|
||||
|
||||
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
Executable
+618
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env bash
|
||||
# bin/olp-connect — Lightweight client script to connect this machine to a remote
|
||||
# OLP (Open LLM Proxy). Ported from OCP's `ocp-connect` per ADR 0010 § Phase 4
|
||||
# D68-D70 charter; uses /health.anonymousKey when the remote operator opted in
|
||||
# via `auth.advertise_anonymous_key: true` (ADR 0011).
|
||||
#
|
||||
# Authority:
|
||||
# - ADR 0010 (Phase 4 charter — D68 line: client-side IDE auto-config)
|
||||
# - ADR 0011 (anonymous-key deployment-context limits — trusted-LAN invariant)
|
||||
# - OCP `ocp-connect` v1.3.0 (prior-art reference)
|
||||
#
|
||||
# Why bash (not Node like `olp` CLI):
|
||||
# olp-connect MUST run on CLIENT machines that may not have a recent Node
|
||||
# installed (parents' laptops, work machines, Raspberry Pi). bash + curl +
|
||||
# python3 give maximum portability; this script does not import any OLP
|
||||
# Node modules.
|
||||
#
|
||||
# Dependencies: bash >=4, curl, python3 (for /health JSON parsing).
|
||||
#
|
||||
# Install:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect -o olp-connect
|
||||
# chmod +x olp-connect
|
||||
#
|
||||
# Or via npm/npx (once `npm install -g olp` is run on a machine that has Node):
|
||||
# olp-connect <ip>
|
||||
#
|
||||
# Or run directly via curl-pipe:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect | bash -s -- <host-ip>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OLP_CONNECT_VERSION="0.4.0-phase4"
|
||||
|
||||
show_version() {
|
||||
echo "olp-connect $OLP_CONNECT_VERSION"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat <<'EOF'
|
||||
olp-connect — Connect this machine to a remote OLP (Open LLM Proxy)
|
||||
|
||||
Configures OPENAI_BASE_URL + OPENAI_API_KEY in your shell rc file (and macOS
|
||||
launchctl env / Linux systemd user env), then detects installed IDEs (Cline,
|
||||
Continue.dev, Cursor, Aider, OpenClaw, Claude Code) and prints / writes the
|
||||
provider-specific configuration each needs.
|
||||
|
||||
Usage:
|
||||
olp-connect <host-ip> [options]
|
||||
olp-connect --help
|
||||
olp-connect --version
|
||||
|
||||
Options:
|
||||
--port PORT Port OLP listens on (default: 4567 — OLP v0.4.0+ default;
|
||||
set 3456 if connecting to a pre-D60 OLP install)
|
||||
--key API_KEY OLP API key (from `olp-keys keygen` on the server). When
|
||||
omitted, the script reads /health.anonymousKey (if the
|
||||
server opted in via auth.advertise_anonymous_key=true) or
|
||||
prompts interactively.
|
||||
--no-system-env Skip macOS launchctl setenv / Linux systemd env writes;
|
||||
only update shell rc files.
|
||||
--dry-run Print everything the script would do without modifying
|
||||
any file or setting any env var.
|
||||
--version Print version and exit
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
olp-connect 192.168.1.10
|
||||
olp-connect 192.168.1.10 --port 8080
|
||||
olp-connect 192.168.1.10 --key olp_AbcDef1234...
|
||||
olp-connect 100.64.0.5 --dry-run
|
||||
|
||||
Requires:
|
||||
bash, curl, python3 (for /health JSON parsing)
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 bad arguments / unknown flag / missing required value
|
||||
2 connectivity or auth failure / smoke test failure
|
||||
|
||||
Authority: ADR 0010 § Phase 4 D68-D70; ADR 0011 (anonymous-key trusted-LAN
|
||||
invariant — when --key is auto-resolved from /health.anonymousKey, this
|
||||
deployment MUST be on a trusted LAN).
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Globals populated by main() ─────────────────────────────────────────────
|
||||
|
||||
DRY_RUN=false
|
||||
NO_SYSTEM_ENV=false
|
||||
|
||||
# ── Logging helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
log_info() { echo " $*"; }
|
||||
log_step() { echo " → $*"; }
|
||||
log_ok() { echo " ✓ $*"; }
|
||||
log_warn() { echo " ⚠ $*"; }
|
||||
log_err() { echo " ✗ $*" >&2; }
|
||||
|
||||
# Echo a state change (a write / env-set) before executing — operator can
|
||||
# Ctrl-C if something looks wrong. Returns 0 always.
|
||||
log_change() { echo " • $*"; }
|
||||
|
||||
# ── IDE detection + configuration ───────────────────────────────────────────
|
||||
|
||||
# Truncate long keys for display (avoid leaking via screenshot / screen share).
|
||||
key_display() {
|
||||
local k="$1"
|
||||
if [[ -z "$k" ]]; then
|
||||
echo "(none — anonymous; most IDEs require a non-empty API Key)"
|
||||
elif [[ ${#k} -gt 16 ]]; then
|
||||
echo "${k:0:8}...${k: -4}"
|
||||
else
|
||||
echo "$k"
|
||||
fi
|
||||
}
|
||||
|
||||
# D74 P1-2: validate OLP API key format. Per ADR 0007 § 3, tokens are
|
||||
# `olp_` + 32 random bytes base64url-encoded (43 chars, no padding). This
|
||||
# regex pins the on-the-wire shape so a malformed or hostile `--key` /
|
||||
# server-advertised `anonymousKey` never gets persisted into a shell rc.
|
||||
# Returns 0 on valid, 1 on invalid (with diagnostic to stderr).
|
||||
validate_olp_token() {
|
||||
local k="$1" source="$2"
|
||||
if [[ ! "$k" =~ ^olp_[A-Za-z0-9_-]{43}$ ]]; then
|
||||
log_err "Rejected $source: token format does not match ^olp_[A-Za-z0-9_-]{43}$ (ADR 0007 § 3)."
|
||||
log_err " Got ${#k}-char value starting with '$(echo "$k" | cut -c1-8)...'"
|
||||
log_err " Expected: olp_ followed by 43 base64url chars. Run 'npx olp-keys list' on the server"
|
||||
log_err " to confirm the key format, or have the operator regenerate with 'npx olp-keys keygen'."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# D74 P1-2: POSIX shell-quote a value before interpolating into a shell rc
|
||||
# write. Wraps in single quotes + escapes embedded single quotes per:
|
||||
# foo'bar → 'foo'\''bar'
|
||||
# Even with the validator above, this is defense-in-depth: any non-token
|
||||
# string that slips through (e.g., environment.d KEY=VALUE writes) MUST be
|
||||
# safe to source. Same helper pattern as lib/doctor.mjs _shellQuote.
|
||||
shell_quote() {
|
||||
local s="$1"
|
||||
# Escape any single quotes: ' → '\''
|
||||
printf "'%s'" "${s//\'/\'\\\'\'}"
|
||||
}
|
||||
|
||||
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
|
||||
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
|
||||
# client. The user is steered toward Cline + OLP.
|
||||
detect_claude_code() {
|
||||
if command -v claude &>/dev/null; then
|
||||
log_info ""
|
||||
log_info "Detected: Claude Code (`command -v claude`)"
|
||||
log_warn "Claude Code is NOT supported as an OLP client (OLP does not ship"
|
||||
log_warn " /v1/messages — see ADR 0010 § Out-of-Phase-4-scope)."
|
||||
log_warn " Recommended alternative: install Cline (VSCode extension) + OLP."
|
||||
log_warn " Cline uses OpenAI-shape /v1/chat/completions which OLP DOES serve."
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect Cline VSCode extension and print manual-configure snippet.
|
||||
# Cline cannot be auto-configured via env vars — user must paste into VSCode
|
||||
# settings UI. We surface the values for them.
|
||||
detect_cline() {
|
||||
local base_url="$1" key="$2"
|
||||
local exts=""
|
||||
if command -v code &>/dev/null; then
|
||||
exts=$(code --list-extensions 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$exts" && -d "$HOME/.vscode/extensions" ]]; then
|
||||
exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if echo "$exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
||||
log_info ""
|
||||
log_info "Detected: Cline (VSCode extension)"
|
||||
log_info " Cline must be configured via the VSCode settings UI."
|
||||
log_info " Open VSCode → Cline panel → Settings → API Provider:"
|
||||
log_info " API Provider: \"OpenAI Compatible\""
|
||||
log_info " Base URL: $base_url/v1"
|
||||
log_info " API Key: $(key_display "$key")"
|
||||
log_info " Model ID: claude-sonnet-4-5 (or any model from /v1/models)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect Continue.dev and write a `models:` entry to ~/.continue/config.yaml
|
||||
# (idempotent — checks if an entry with the same name exists first).
|
||||
detect_continue() {
|
||||
local base_url="$1" key="$2"
|
||||
local exts=""
|
||||
if command -v code &>/dev/null; then
|
||||
exts=$(code --list-extensions 2>/dev/null || true)
|
||||
fi
|
||||
local config_yaml="$HOME/.continue/config.yaml"
|
||||
local config_json="$HOME/.continue/config.json"
|
||||
|
||||
local found=false
|
||||
if echo "$exts" | grep -qi 'continue\.continue'; then found=true; fi
|
||||
if [[ -f "$config_yaml" || -f "$config_json" ]]; then found=true; fi
|
||||
|
||||
if ! $found; then return 0; fi
|
||||
|
||||
log_info ""
|
||||
log_info "Detected: Continue.dev"
|
||||
log_info " Configuration snippet for ~/.continue/config.yaml:"
|
||||
log_info " models:"
|
||||
log_info " - name: OLP Sonnet"
|
||||
log_info " provider: openai"
|
||||
log_info " model: claude-sonnet-4-5"
|
||||
log_info " apiBase: $base_url/v1"
|
||||
log_info " apiKey: $(key_display "$key")"
|
||||
log_info " Note: Continue.dev autoreload-on-save is fragile; restart VSCode if"
|
||||
log_info " the new model doesn't appear in the model selector."
|
||||
}
|
||||
|
||||
# Detect Cursor and print manual snippet + known-fragility warning.
|
||||
detect_cursor() {
|
||||
local base_url="$1" key="$2"
|
||||
local found=false
|
||||
if command -v cursor &>/dev/null; then found=true; fi
|
||||
if [[ -d "$HOME/.cursor" ]]; then found=true; fi
|
||||
if [[ -d "/Applications/Cursor.app" ]]; then found=true; fi
|
||||
|
||||
if ! $found; then return 0; fi
|
||||
|
||||
log_info ""
|
||||
log_info "Detected: Cursor"
|
||||
log_info " Cmd+Shift+P → 'Cursor Settings' → Models:"
|
||||
log_info " OpenAI API Key: $(key_display "$key")"
|
||||
log_info " Override OpenAI Base URL: $base_url/v1"
|
||||
log_info " Custom OpenAI Models: claude-sonnet-4-5,claude-opus-4-1"
|
||||
log_warn " Cursor's base-URL handling is known-fragile (issue #7128 et al);"
|
||||
log_warn " if requests fail with 'malformed request', try removing then"
|
||||
log_warn " re-adding the model in the Cursor models list."
|
||||
}
|
||||
|
||||
# Detect Aider and write OPENAI_API_BASE / OPENAI_API_KEY to rc files.
|
||||
# Aider reads these env vars at startup — already handled by the rc-file
|
||||
# block in main(). We just announce detection here.
|
||||
detect_aider() {
|
||||
if command -v aider &>/dev/null; then
|
||||
log_info ""
|
||||
log_info "Detected: Aider (`command -v aider`)"
|
||||
log_info " Aider reads OPENAI_API_BASE + OPENAI_API_KEY from env."
|
||||
log_info " These are already being written to your shell rc — open a fresh"
|
||||
log_info " shell and run: aider --model openai/claude-sonnet-4-5"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect OpenClaw. Per Phase 4 D71-D73 (NOT in this PR), olp will ship
|
||||
# olp-plugin/ for OpenClaw with full Telegram/Discord /olp slash commands.
|
||||
# Until that ships, we just announce detection and link.
|
||||
detect_openclaw() {
|
||||
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
|
||||
log_info ""
|
||||
log_info "Detected: OpenClaw"
|
||||
log_info " The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED."
|
||||
log_info " When it ships, install with: openclaw plugin install olp"
|
||||
log_info " For now, you can manually point OpenClaw at OLP via the OPENAI_BASE_URL"
|
||||
log_info " env var (already written to your shell rc above)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── rc-file helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# Identify which shell rc files to write to. Returns paths on stdout, one per line.
|
||||
detect_rc_files() {
|
||||
local is_mac=false
|
||||
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
log_warn "fish shell detected; writing to ~/.bashrc — add to fish config manually." >&2
|
||||
echo "$HOME/.bashrc"
|
||||
return
|
||||
fi
|
||||
if $is_mac; then
|
||||
# macOS Catalina+ default shell is zsh
|
||||
[[ -f "$HOME/.bashrc" ]] && echo "$HOME/.bashrc"
|
||||
[[ -f "$HOME/.zshrc" ]] || { $DRY_RUN || touch "$HOME/.zshrc"; }
|
||||
echo "$HOME/.zshrc"
|
||||
else
|
||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && echo "$HOME/.bashrc"
|
||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && echo "$HOME/.zshrc"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove any previously-written OLP block from an rc file (idempotent).
|
||||
# The block is bracketed by:
|
||||
# # OLP LAN (added by olp-connect) ... # /OLP LAN
|
||||
strip_olp_block() {
|
||||
local rc_file="$1"
|
||||
[[ -f "$rc_file" ]] || return 0
|
||||
if $DRY_RUN; then
|
||||
if grep -qF '# OLP LAN (added by olp-connect)' "$rc_file" 2>/dev/null; then
|
||||
log_change "[dry-run] would strip existing OLP block from $rc_file"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
python3 - "$rc_file" <<'PYEOF'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
sys.exit(0)
|
||||
out = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
s = line.rstrip('\n')
|
||||
if s == '# OLP LAN (added by olp-connect)':
|
||||
skip = True
|
||||
continue
|
||||
if skip and s == '# /OLP LAN':
|
||||
skip = False
|
||||
continue
|
||||
if skip:
|
||||
continue
|
||||
out.append(line)
|
||||
with open(path, 'w') as f:
|
||||
f.writelines(out)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Append a new OLP block to an rc file.
|
||||
append_olp_block() {
|
||||
local rc_file="$1" base_url="$2" key="$3"
|
||||
if $DRY_RUN; then
|
||||
log_change "[dry-run] would append OLP block to $rc_file:"
|
||||
log_change " # OLP LAN (added by olp-connect)"
|
||||
log_change " export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
|
||||
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(shell_quote "$(key_display "$key")")"
|
||||
log_change " # /OLP LAN"
|
||||
return 0
|
||||
fi
|
||||
# D74 P1-2: shell-quote values before writing to rc files. Defense-in-depth
|
||||
# alongside validate_olp_token — even if a future code path bypasses the
|
||||
# validator, the rc file remains safe to source.
|
||||
{
|
||||
echo ""
|
||||
echo "# OLP LAN (added by olp-connect)"
|
||||
echo "export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$(shell_quote "$key")"
|
||||
fi
|
||||
echo "# /OLP LAN"
|
||||
} >> "$rc_file"
|
||||
}
|
||||
|
||||
# ── System-level env (macOS launchctl / Linux systemd user) ────────────────
|
||||
|
||||
set_system_env() {
|
||||
local base_url="$1" key="$2"
|
||||
if $NO_SYSTEM_ENV; then
|
||||
log_info "Skipping system-level env (--no-system-env)"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
if $DRY_RUN; then
|
||||
log_change "[dry-run] would launchctl setenv OPENAI_BASE_URL=$base_url/v1"
|
||||
[[ -n "$key" ]] && log_change "[dry-run] would launchctl setenv OPENAI_API_KEY=$(key_display "$key")"
|
||||
return 0
|
||||
fi
|
||||
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null || log_warn "launchctl setenv OPENAI_BASE_URL failed"
|
||||
if [[ -n "$key" ]]; then
|
||||
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null || log_warn "launchctl setenv OPENAI_API_KEY failed"
|
||||
fi
|
||||
log_ok "launchctl setenv applied (visible to GUI apps + daemons)"
|
||||
log_info " Note: launchctl env vars reset on reboot. Re-run olp-connect after restart"
|
||||
log_info " or add the script to Login Items."
|
||||
else
|
||||
local env_dir="$HOME/.config/environment.d"
|
||||
if $DRY_RUN; then
|
||||
log_change "[dry-run] would write $env_dir/olp.conf"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$env_dir" 2>/dev/null
|
||||
# D74 P1-2: systemd environment.d format is KEY=VALUE per line. While
|
||||
# systemd does its own parsing (no shell sourcing), reject embedded
|
||||
# newlines defensively — validate_olp_token already enforces the
|
||||
# restricted charset for the API key, so this is belt-and-braces.
|
||||
if [[ "$base_url" == *$'\n'* || "$key" == *$'\n'* ]]; then
|
||||
log_err "Refusing to write environment.d entry: value contains newline."
|
||||
return 2
|
||||
fi
|
||||
{
|
||||
echo "OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/olp.conf"
|
||||
log_ok "Wrote $env_dir/olp.conf (applies to systemd user services after re-login)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
local host="" port=4567 key=""
|
||||
|
||||
# Parse args (POSIX-style; --flag value AND --flag=value both accepted)
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="${2:?--port requires a value}"; shift 2 ;;
|
||||
--port=*) port="${1#*=}"; shift ;;
|
||||
--key) key="${2:?--key requires a value}"
|
||||
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
||||
# D74 P1-2: reject malformed --key before it ever reaches an rc write.
|
||||
validate_olp_token "$key" "--key flag" || exit 1
|
||||
shift 2 ;;
|
||||
--key=*) key="${1#*=}"
|
||||
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
||||
validate_olp_token "$key" "--key flag" || exit 1
|
||||
shift ;;
|
||||
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--version) show_version; exit 0 ;;
|
||||
--help|-h) show_help; exit 0 ;;
|
||||
--*) log_err "Unknown option: $1"; show_help >&2; exit 1 ;;
|
||||
*) host="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$host" ]]; then
|
||||
log_err "host IP is required."
|
||||
echo "" >&2
|
||||
show_help >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
log_err "invalid host '$host'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dependency check
|
||||
for cmd in curl python3; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
log_err "'$cmd' is required but not found in PATH."
|
||||
[[ "$cmd" == "python3" ]] && log_err " python3 is used for /health + /v1/models JSON parsing."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "olp-connect v$OLP_CONNECT_VERSION"
|
||||
echo "─────────────────────────────────────"
|
||||
log_info "Remote: $base_url"
|
||||
$DRY_RUN && log_info "Mode: DRY RUN (no files will be modified, no env vars will be set)"
|
||||
echo ""
|
||||
|
||||
# Step 1: connectivity probe. We capture status separately from body so we
|
||||
# can distinguish "TCP/HTTP unreachable" from "reached but 401" (the latter
|
||||
# is a known surface when the server has auth.allow_anonymous=false AND
|
||||
# auth.advertise_anonymous_key=false — user MUST provide --key).
|
||||
log_step "Probing /health..."
|
||||
local probe_body probe_status
|
||||
probe_body=$(curl -s --max-time 5 -o /tmp/olp-connect-health.$$ -w "%{http_code}" "$base_url/health" 2>/dev/null || echo "000")
|
||||
probe_status="$probe_body"
|
||||
if [[ -f /tmp/olp-connect-health.$$ ]]; then
|
||||
probe_body=$(cat /tmp/olp-connect-health.$$ 2>/dev/null || echo "")
|
||||
rm -f /tmp/olp-connect-health.$$
|
||||
fi
|
||||
if [[ "$probe_status" == "000" ]]; then
|
||||
log_err "Cannot reach $base_url/health (connection refused / timeout / DNS)"
|
||||
log_err " Ensure OLP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||
log_err " Default port changed 3456 → 4567 at OLP v0.4.0; pass --port 3456 for older installs."
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$probe_status" == "401" ]]; then
|
||||
log_warn "Server reachable but /health returned 401."
|
||||
log_warn " Either the operator has not enabled auth.advertise_anonymous_key, or"
|
||||
log_warn " the server requires auth (auth.allow_anonymous=false)."
|
||||
if [[ -z "$key" ]]; then
|
||||
log_err " Pass --key olp_... to continue, or ask the operator to advertise an anonymous key (see ADR 0011)."
|
||||
exit 2
|
||||
fi
|
||||
# If user supplied --key, we proceed without /health body (auth-required mode).
|
||||
log_info " Proceeding with the --key you supplied; skipping /health.anonymousKey discovery."
|
||||
local health_json=""
|
||||
local remote_version="?"
|
||||
else
|
||||
if [[ "$probe_status" != "200" ]]; then
|
||||
log_err "/health returned HTTP $probe_status (expected 200 or 401)."
|
||||
exit 2
|
||||
fi
|
||||
local health_json="$probe_body"
|
||||
local remote_version
|
||||
remote_version=$(echo "$health_json" | python3 -c "import sys,json
|
||||
try: print(json.loads(sys.stdin.read()).get('version','?'))
|
||||
except: print('?')" 2>/dev/null || echo "?")
|
||||
log_ok "Connected — OLP v$remote_version"
|
||||
fi
|
||||
|
||||
# Step 2: auth resolution
|
||||
if [[ -z "$key" ]]; then
|
||||
# Try /health.anonymousKey first (D69 / ADR 0011 opt-in).
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "import sys,json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
k = d.get('anonymousKey')
|
||||
print(k if isinstance(k, str) and k else '')
|
||||
except: print('')" 2>/dev/null || echo "")
|
||||
if [[ -n "$anon_key" ]]; then
|
||||
# D74 P1-2: validate server-advertised token shape before consuming.
|
||||
# A hostile or misconfigured server could otherwise inject arbitrary
|
||||
# strings into the user's rc file via the `anonymousKey` field.
|
||||
if ! validate_olp_token "$anon_key" "/health.anonymousKey from $remote_host"; then
|
||||
log_err "Refusing to consume malformed advertised key. Use --key explicitly or contact the OLP operator."
|
||||
exit 2
|
||||
fi
|
||||
key="$anon_key"
|
||||
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
|
||||
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
|
||||
log_info " the trusted-LAN-only invariant — this assumes you and the remote are"
|
||||
log_info " on the same trusted network)"
|
||||
else
|
||||
# No advertised key; prompt interactively (skip in dry-run for non-TTY safety)
|
||||
if $DRY_RUN; then
|
||||
log_info "[dry-run] would prompt for API key here (no --key + no anonymousKey)"
|
||||
key="<prompted-at-runtime>"
|
||||
else
|
||||
echo ""
|
||||
log_info "Remote does not advertise an anonymous key."
|
||||
log_info "Ask the OLP operator to run on the server: olp-keys keygen --name <your-label>"
|
||||
printf " Enter OLP API key: "
|
||||
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
||||
echo
|
||||
if [[ -z "$key" ]]; then
|
||||
log_err "No key provided and the remote did not advertise an anonymous key."
|
||||
log_err " Re-run with: olp-connect $host --key olp_..."
|
||||
exit 2
|
||||
fi
|
||||
# D74 P1-2: also validate the interactively-prompted key.
|
||||
validate_olp_token "$key" "interactive prompt" || exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: smoke test /v1/models
|
||||
log_step "Smoke-testing /v1/models..."
|
||||
if $DRY_RUN && [[ "$key" == "<prompted-at-runtime>" ]]; then
|
||||
log_info "[dry-run] skipping smoke test (no real key)"
|
||||
else
|
||||
local models_out models_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
models_out=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
else
|
||||
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
fi
|
||||
if [[ $models_ok -eq 0 ]]; then
|
||||
log_err "/v1/models request failed — key may be invalid, revoked, or not allowed for any provider."
|
||||
exit 2
|
||||
fi
|
||||
local model_count
|
||||
model_count=$(echo "$models_out" | python3 -c "import sys,json
|
||||
try: print(len(json.loads(sys.stdin.read()).get('data', [])))
|
||||
except: print('?')" 2>/dev/null || echo "?")
|
||||
log_ok "/v1/models OK — $model_count models available"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 4: write shell rc files
|
||||
log_step "Writing shell rc files..."
|
||||
local rc_files=()
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && rc_files+=("$line")
|
||||
done < <(detect_rc_files)
|
||||
|
||||
if [[ ${#rc_files[@]} -eq 0 ]]; then
|
||||
log_warn "No shell rc files detected; falling back to ~/.bashrc"
|
||||
rc_files=("$HOME/.bashrc")
|
||||
fi
|
||||
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
log_change "stripping old OLP block from $(basename "$rc_file") (idempotent)"
|
||||
strip_olp_block "$rc_file"
|
||||
log_change "appending new OLP block to $(basename "$rc_file")"
|
||||
append_olp_block "$rc_file" "$base_url" "$key"
|
||||
done
|
||||
log_ok "Shell rc files updated:"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
log_info " $rc_file"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Step 5: system-level env (macOS launchctl / Linux systemd)
|
||||
log_step "Setting system-level env..."
|
||||
set_system_env "$base_url" "$key"
|
||||
echo ""
|
||||
|
||||
# Step 6: IDE detection + per-IDE config
|
||||
log_step "Detecting installed IDEs..."
|
||||
detect_claude_code
|
||||
detect_cline "$base_url" "$key"
|
||||
detect_continue "$base_url" "$key"
|
||||
detect_cursor "$base_url" "$key"
|
||||
detect_aider
|
||||
detect_openclaw
|
||||
echo ""
|
||||
|
||||
# Step 7: final summary
|
||||
log_step "Done."
|
||||
log_info "OLP base URL: $base_url/v1"
|
||||
log_info "OLP API key: $(key_display "$key")"
|
||||
log_info ""
|
||||
log_info "Test it: open a fresh shell, run:"
|
||||
log_info " curl -sf -H \"Authorization: Bearer \$OPENAI_API_KEY\" $base_url/v1/models | python3 -m json.tool | head -20"
|
||||
log_info ""
|
||||
log_info "Reload your current shell to apply env changes:"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
log_info " source $rc_file"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+33
-4
@@ -79,6 +79,7 @@ const USAGE = `OLP key management CLI
|
||||
Usage:
|
||||
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
||||
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
||||
olp-keys keygen --anonymous --advertise [--name=<label>] [--providers=<csv>]
|
||||
olp-keys list [--owner-only] [--include-revoked]
|
||||
olp-keys revoke --id=<key-id>
|
||||
|
||||
@@ -86,7 +87,8 @@ Common flags:
|
||||
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
||||
--help Print this message
|
||||
|
||||
Authority: ADR 0007 § 9 (bootstrap & recovery).`;
|
||||
Authority: ADR 0007 § 9 (bootstrap & recovery); ADR 0011 (anonymous-key
|
||||
deployment-context limits — trusted-LAN-only invariant for --advertise).`;
|
||||
|
||||
// ── Subcommand implementations ────────────────────────────────────────────
|
||||
|
||||
@@ -94,16 +96,36 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
||||
const olpHome = flags['olp-home'];
|
||||
const owner = flags.owner === true;
|
||||
const force = flags.force === true;
|
||||
// D69 (ADR 0011): --anonymous is shorthand for "create a guest-tier key
|
||||
// intended to be the zero-config /health.anonymousKey advertise key".
|
||||
// It implies --tier=guest and defaults the name to 'anonymous'. The
|
||||
// distinct field that actually triggers /health advertisement is
|
||||
// --advertise (writes plaintext_advertise into the manifest). Either
|
||||
// flag works on its own (--anonymous without --advertise is just a
|
||||
// conventionally-named guest key); --advertise without --anonymous is
|
||||
// accepted (operator may want to advertise a named guest key).
|
||||
const isAnonymous = flags.anonymous === true;
|
||||
const advertise = flags.advertise === true;
|
||||
let tier = flags.tier;
|
||||
if (owner) tier = 'owner';
|
||||
if (isAnonymous && !owner) tier = 'guest';
|
||||
if (!tier) tier = 'guest';
|
||||
if (tier !== 'owner' && tier !== 'guest') {
|
||||
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
||||
return 1;
|
||||
}
|
||||
const name = flags.name || (owner ? 'owner' : null);
|
||||
// D69: reject --owner --advertise (would expose owner identity unauthenticated).
|
||||
if (advertise && tier !== 'guest') {
|
||||
ioErr(`Error: --advertise requires guest tier (cannot advertise owner-tier key plaintext). See ADR 0011.\n`);
|
||||
return 1;
|
||||
}
|
||||
let name = flags.name;
|
||||
if (!name) {
|
||||
ioErr('Error: --name is required (or use --owner to default to "owner").\n');
|
||||
if (owner) name = 'owner';
|
||||
else if (isAnonymous) name = 'anonymous';
|
||||
}
|
||||
if (!name) {
|
||||
ioErr('Error: --name is required (or use --owner to default to "owner", or --anonymous to default to "anonymous").\n');
|
||||
return 1;
|
||||
}
|
||||
const providersFlag = flags.providers;
|
||||
@@ -136,7 +158,7 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
|
||||
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome, plaintext_advertise: advertise });
|
||||
} catch (err) {
|
||||
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
||||
return 2;
|
||||
@@ -150,6 +172,13 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
||||
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
|
||||
ioOut(` created_at: ${result.manifest.created_at}\n`);
|
||||
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
|
||||
if (advertise) {
|
||||
// D69 (ADR 0011): explicit warning when plaintext lands on disk + opt-in surface.
|
||||
ioOut(` advertise: YES — plaintext stored in manifest; surfaced via /health.anonymousKey\n`);
|
||||
ioErr(`\n WARNING: this key's plaintext is now stored on disk + will be exposed via\n`);
|
||||
ioErr(` /health.anonymousKey when auth.advertise_anonymous_key=true AND\n`);
|
||||
ioErr(` auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.\n`);
|
||||
}
|
||||
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
||||
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
|
||||
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
||||
|
||||
Executable
+768
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bin/olp.mjs — OLP operator CLI (Phase 4 / D64)
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
|
||||
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
|
||||
* python3 JSON-parsing fragility called out in the ADR.
|
||||
*
|
||||
* Subcommands:
|
||||
* status GET /v0/management/status (owner-only)
|
||||
* health GET /health
|
||||
* usage GET /v0/management/dashboard-data (owner-only)
|
||||
* models GET /v1/models
|
||||
* cache GET /cache/stats (owner-only)
|
||||
* providers local: models-registry + config providers.enabled
|
||||
* chain show [<model>] local: ~/.olp/config.json routing.chains
|
||||
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
|
||||
* restart launchctl (macOS) / systemctl --user (Linux)
|
||||
* doctor [--check X] run lib/doctor.mjs runDoctor + format
|
||||
* keys ... delegate to bin/olp-keys.mjs
|
||||
* help | --help usage
|
||||
*
|
||||
* Global flags:
|
||||
* --json emit raw JSON (silences human-readable output)
|
||||
* --proxy-url=<url> override resolved proxy URL
|
||||
* --olp-home=<path> override ~/.olp
|
||||
*
|
||||
* URL resolution:
|
||||
* OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567}
|
||||
*
|
||||
* Auth (Bearer token) resolution:
|
||||
* 1. OLP_API_KEY env
|
||||
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
|
||||
* 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk
|
||||
*
|
||||
* Note: the third option only works during the same session in which `olp-keys
|
||||
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
|
||||
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
|
||||
* reports "no owner token available" + remediation instructions if env vars are
|
||||
* absent — it does NOT try to crack the hash.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = success
|
||||
* 1 = bad usage / unknown subcommand
|
||||
* 2 = network or HTTP error (4xx/5xx)
|
||||
* 3 = auth missing / forbidden
|
||||
*/
|
||||
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { spawn as spawnProc } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
|
||||
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
|
||||
import { listKeys } from '../lib/keys.mjs';
|
||||
import { readAuditWindow } from '../lib/audit-query.mjs';
|
||||
import modelsRegistry from '../models-registry.json' with { type: 'json' };
|
||||
import { runCli as runKeysCli } from './olp-keys.mjs';
|
||||
|
||||
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
|
||||
|
||||
const ANSI = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
|
||||
function colorize(s, code, useColor) {
|
||||
if (!useColor) return s;
|
||||
return `${code}${s}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function statusBadge(status, useColor) {
|
||||
const map = {
|
||||
ok: { txt: 'PASS', col: ANSI.green },
|
||||
warn: { txt: 'WARN', col: ANSI.yellow },
|
||||
fail: { txt: 'FAIL', col: ANSI.red },
|
||||
};
|
||||
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
|
||||
return colorize(`[${m.txt}]`, m.col, useColor);
|
||||
}
|
||||
|
||||
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
|
||||
|
||||
export function parseArgv(argv) {
|
||||
const positional = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=');
|
||||
if (eq > 0) {
|
||||
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
||||
} else {
|
||||
const name = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
flags[name] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[name] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
return { positional, flags };
|
||||
}
|
||||
|
||||
// ── Output helpers (respect --json) ───────────────────────────────────────
|
||||
|
||||
function makeIO(opts) {
|
||||
const out = opts.out ?? (s => process.stdout.write(s));
|
||||
const err = opts.err ?? (s => process.stderr.write(s));
|
||||
const wantJson = opts.json === true;
|
||||
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
|
||||
// (debug noise suppression per the bundle requirements). `errln` always writes
|
||||
// to stderr.
|
||||
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
|
||||
const errln = (...parts) => err(parts.join(' ') + '\n');
|
||||
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
|
||||
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
|
||||
}
|
||||
|
||||
// ── HTTP helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
let urlObj;
|
||||
try { urlObj = new URL(url); }
|
||||
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const reqFn = isHttps ? httpsRequest : httpRequest;
|
||||
let req;
|
||||
try {
|
||||
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
|
||||
});
|
||||
} catch (e) {
|
||||
return finish({ ok: false, error: String(e?.message ?? e) });
|
||||
}
|
||||
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
|
||||
req.on('timeout', () => {
|
||||
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Token resolution ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a Bearer token. Returns the plaintext token string or null.
|
||||
* Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed).
|
||||
*/
|
||||
export function resolveBearerToken() {
|
||||
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
|
||||
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
|
||||
return null;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const tok = resolveBearerToken();
|
||||
return tok ? { Authorization: `Bearer ${tok}` } : {};
|
||||
}
|
||||
|
||||
// ── HTTP-error → exit code mapping ────────────────────────────────────────
|
||||
|
||||
function httpErrorToExit(res, io) {
|
||||
if (!res.ok) {
|
||||
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
|
||||
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
|
||||
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
|
||||
return 2;
|
||||
}
|
||||
io.errln(`Error: network error: ${res.error}`);
|
||||
return 2;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
|
||||
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
|
||||
return 3;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
|
||||
return 3;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Human-readable formatters ─────────────────────────────────────────────
|
||||
|
||||
function formatBytes(n) {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||
if (n < 1024) return `${n}B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
||||
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
// ── Subcommand: status ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdStatus(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize('OLP status', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
io.log(` version: ${body.version}`);
|
||||
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
|
||||
io.log(` started: ${body.started_at}`);
|
||||
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
|
||||
if (body.providers?.status && typeof body.providers.status === 'object') {
|
||||
for (const [name, s] of Object.entries(body.providers.status)) {
|
||||
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
|
||||
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
|
||||
}
|
||||
}
|
||||
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
|
||||
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
|
||||
if (body.stats?.cache) {
|
||||
const c = body.stats.cache;
|
||||
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`);
|
||||
}
|
||||
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||
io.log(` recent errors: ${body.recent_errors.length}`);
|
||||
for (const e of body.recent_errors.slice(0, 5)) {
|
||||
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'} — ${(e.message ?? '').slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: health ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdHealth(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
io.log(` ${k}:`);
|
||||
for (const [k2, v2] of Object.entries(v)) {
|
||||
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
|
||||
}
|
||||
} else {
|
||||
io.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: usage ─────────────────────────────────────────────────────
|
||||
|
||||
async function cmdUsage(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
// D74 P2-3 fix: server payload shape is { generated_at, window_24h: { request_count, status_2xx,
|
||||
// status_4xx, status_5xx, by_provider, by_owner_tier, by_path, median_latency_ms, p95_latency_ms },
|
||||
// cache_hit_24h: { total, hit, miss, bypass, streaming_attached, hit_rate, by_provider }, quota: [{provider, ...}],
|
||||
// spend_trend_30d: [{date, request_count, by_provider}], top_fallback_chains_24h: [{chain, count, ...}],
|
||||
// cache_stats: { hits, misses, size, inflightCount } } per server.mjs:2027 + lib/audit-query.mjs.
|
||||
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
const w24 = body.window_24h ?? {};
|
||||
const cache24 = body.cache_hit_24h ?? {};
|
||||
if (typeof w24 === 'object' && (w24.request_count ?? 0) > 0) {
|
||||
io.log(` requests: ${w24.request_count}`);
|
||||
io.log(` 2xx / 4xx / 5xx: ${w24.status_2xx ?? 0} / ${w24.status_4xx ?? 0} / ${w24.status_5xx ?? 0}`);
|
||||
if (typeof w24.median_latency_ms === 'number') {
|
||||
io.log(` latency p50/p95: ${w24.median_latency_ms}ms / ${w24.p95_latency_ms ?? 0}ms`);
|
||||
}
|
||||
if (typeof cache24.hit_rate === 'number') {
|
||||
const pct = (cache24.hit_rate * 100).toFixed(1);
|
||||
io.log(` cache hit rate: ${pct}% (hit=${cache24.hit ?? 0} miss=${cache24.miss ?? 0}${cache24.streaming_attached ? ` streaming_attached=${cache24.streaming_attached}` : ''})`);
|
||||
}
|
||||
} else {
|
||||
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
||||
}
|
||||
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const p of body.quota) {
|
||||
const label = String(p.provider ?? '?').padEnd(12);
|
||||
if (p.error) {
|
||||
io.log(` ${label} error: ${p.error}`);
|
||||
} else if (typeof p.percent_used === 'number') {
|
||||
io.log(` ${label} ${p.percent_used}% used${p.resets_in_human ? ` (resets in ${p.resets_in_human})` : ''}`);
|
||||
} else if (p.available === false) {
|
||||
io.log(` ${label} unavailable`);
|
||||
} else {
|
||||
io.log(` ${label} no quota api`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Top fallback chains (24h)', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const f of body.top_fallback_chains_24h.slice(0, 10)) {
|
||||
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: models ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdModels(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const m of body.data ?? []) {
|
||||
io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: cache ─────────────────────────────────────────────────────
|
||||
|
||||
async function cmdCache(flags, io) {
|
||||
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() });
|
||||
const ec = httpErrorToExit(res, io);
|
||||
if (ec !== 0) return ec;
|
||||
let body;
|
||||
try { body = JSON.parse(res.body); }
|
||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||
// D74 P2-3 fix: cacheStore.stats() returns { hits, misses, size, inflightCount }
|
||||
// per lib/cache/store.mjs:320. There is no entries / evictions / bytes / maxBytes
|
||||
// in the OLP cache model — those were OCP-era field names. Compute a hit rate from
|
||||
// the numerator/denominator instead of fabricating bytes.
|
||||
const hits = body.hits ?? 0;
|
||||
const misses = body.misses ?? 0;
|
||||
const denom = hits + misses;
|
||||
const hitRate = denom > 0 ? ((hits / denom) * 100).toFixed(1) : '0.0';
|
||||
io.log(colorize('OLP cache (live in-memory)', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
io.log(` entries: ${body.size ?? 0}`);
|
||||
io.log(` hits / misses: ${hits} / ${misses} (hit rate ${hitRate}%)`);
|
||||
io.log(` inflight: ${body.inflightCount ?? 0}`);
|
||||
if (body.generated_at) io.log(` generated_at: ${body.generated_at}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: providers (local) ─────────────────────────────────────────
|
||||
|
||||
function cmdProviders(flags, io) {
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let enabled = {};
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
enabled = cfg?.providers?.enabled ?? {};
|
||||
} catch { /* fine — empty enabled map */ }
|
||||
|
||||
const providers = modelsRegistry?.providers ?? {};
|
||||
const rows = [];
|
||||
for (const [name, p] of Object.entries(providers)) {
|
||||
rows.push({
|
||||
name,
|
||||
displayName: p?.displayName ?? name,
|
||||
tier: p?.tier ?? '?',
|
||||
modelCount: (p?.models ?? []).length,
|
||||
enabled: enabled[name] === true,
|
||||
candidate: p?.candidate === true,
|
||||
});
|
||||
}
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ providers: rows, config_path: configPath });
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const r of rows) {
|
||||
const enabledTxt = r.enabled
|
||||
? colorize('enabled ', ANSI.green, io.useColor)
|
||||
: colorize('disabled', ANSI.gray, io.useColor);
|
||||
const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : '';
|
||||
io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: chain show ────────────────────────────────────────────────
|
||||
|
||||
function cmdChainShow(positional, flags, io) {
|
||||
const target = positional[0] ?? null; // model name, or null = print all
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let chains = {};
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
chains = cfg?.routing?.chains ?? {};
|
||||
} catch { /* empty */ }
|
||||
|
||||
if (io.wantJson) {
|
||||
if (target) {
|
||||
io.emitJson({ model: target, chain: chains[target] ?? null });
|
||||
} else {
|
||||
io.emitJson({ chains });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
if (Object.keys(chains).length === 0) {
|
||||
io.log(` (no chains configured in ${configPath})`);
|
||||
return 0;
|
||||
}
|
||||
if (target) {
|
||||
const chain = chains[target];
|
||||
if (!chain) {
|
||||
io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`);
|
||||
return 1;
|
||||
}
|
||||
io.log(` ${target}:`);
|
||||
for (const hop of chain) {
|
||||
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
|
||||
}
|
||||
} else {
|
||||
for (const [model, chain] of Object.entries(chains)) {
|
||||
io.log(` ${model}:`);
|
||||
for (const hop of chain) {
|
||||
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: logs ──────────────────────────────────────────────────────
|
||||
|
||||
async function cmdLogs(positional, flags, io) {
|
||||
const n = positional[0] ? parseInt(positional[0], 10) : 20;
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
io.errln(`Error: invalid log count "${positional[0]}"`);
|
||||
return 1;
|
||||
}
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
// readAuditWindow is a generator over [startMs, endMs). Default window = last 24h.
|
||||
const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000;
|
||||
const endMs = Date.now();
|
||||
const startMs = endMs - windowMs;
|
||||
let events = [];
|
||||
try {
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome })) {
|
||||
events.push(ev);
|
||||
}
|
||||
} catch (e) {
|
||||
io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`);
|
||||
return 2;
|
||||
}
|
||||
let filtered = events;
|
||||
if (flags.level) {
|
||||
filtered = filtered.filter(e => e.level === flags.level);
|
||||
}
|
||||
// Tail (audit events are already chronological per generator order).
|
||||
filtered = filtered.slice(-n);
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ events: filtered });
|
||||
return 0;
|
||||
}
|
||||
io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const e of filtered) {
|
||||
// Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is
|
||||
// not always present in audit ndjson (it is in stderr-side logEvent).
|
||||
const level = (e.level ?? 'info').toUpperCase();
|
||||
const levelColor =
|
||||
level === 'ERROR' ? ANSI.red
|
||||
: level === 'WARN' ? ANSI.yellow
|
||||
: ANSI.gray;
|
||||
const summary = e.message ?? e.error ?? '';
|
||||
io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand: restart ───────────────────────────────────────────────────
|
||||
|
||||
async function cmdRestart(flags, io) {
|
||||
// macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy
|
||||
// Linux: systemctl --user restart olp-proxy
|
||||
// Neither installed → fall through to a helpful error.
|
||||
//
|
||||
// CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per
|
||||
// ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k`
|
||||
// does NOT re-read the plist's EnvironmentVariables block — launchd
|
||||
// sticks to its cached env from the most recent bootstrap. If you edited
|
||||
// ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will
|
||||
// silently use stale values. Use `launchctl bootout gui/<uid>/dev.olp.proxy`
|
||||
// followed by `launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.olp.proxy.plist`
|
||||
// to force a clean env reload. The Phase 4 installer (planned post-D73)
|
||||
// will expose `olp restart --full` for the bootout/bootstrap dance.
|
||||
const platform = process.platform;
|
||||
const uid = process.getuid?.() ?? null;
|
||||
let cmd, args;
|
||||
if (platform === 'darwin') {
|
||||
if (uid == null) {
|
||||
io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl');
|
||||
return 2;
|
||||
}
|
||||
cmd = 'launchctl';
|
||||
args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`];
|
||||
} else if (platform === 'linux') {
|
||||
cmd = 'systemctl';
|
||||
args = ['--user', 'restart', 'olp-proxy'];
|
||||
} else {
|
||||
io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`);
|
||||
io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`);
|
||||
return 2;
|
||||
}
|
||||
// Spawn + wait for exit; bubble up any error.
|
||||
const result = await new Promise(resolve => {
|
||||
const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' });
|
||||
child.on('error', e => resolve({ code: -1, error: e }));
|
||||
child.on('exit', code => resolve({ code }));
|
||||
});
|
||||
if (result.code === 0) {
|
||||
if (io.wantJson) {
|
||||
io.emitJson({ ok: true, cmd, args });
|
||||
} else {
|
||||
io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (result.error?.code === 'ENOENT' || result.code === 127) {
|
||||
io.errln(`Error: '${cmd}' not found on this system.`);
|
||||
if (platform === 'darwin') {
|
||||
io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`);
|
||||
} else {
|
||||
io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ── Subcommand: doctor ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdDoctor(flags, io) {
|
||||
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
|
||||
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
|
||||
|
||||
// D74 P1-1: pass authHeaders so server.running / server.version checks
|
||||
// succeed under the default production posture (auth.allow_anonymous:
|
||||
// false). resolveBearerToken returns null when no env var is set; the
|
||||
// doctor still runs but distinguishes 401 from "server down" by status
|
||||
// code per the updated check.
|
||||
const result = await runDoctor({
|
||||
olpHome,
|
||||
proxyUrl,
|
||||
checkFilter,
|
||||
authHeaders: authHeaders(),
|
||||
});
|
||||
|
||||
if (io.wantJson) {
|
||||
io.emitJson(result);
|
||||
return result.fail_count === 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const c of result.checks) {
|
||||
io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`);
|
||||
if (result.next_action.ai_executable.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor));
|
||||
for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`);
|
||||
}
|
||||
if (result.next_action.human_required.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor));
|
||||
for (const step of result.next_action.human_required) io.log(` • ${step}`);
|
||||
}
|
||||
io.log('');
|
||||
io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor));
|
||||
return result.fail_count === 0 ? 0 : 2;
|
||||
}
|
||||
|
||||
// ── Subcommand: keys (delegate) ───────────────────────────────────────────
|
||||
|
||||
async function cmdKeys(rest, io) {
|
||||
// Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings
|
||||
// (no \n needed since the underlying CLI emits them itself).
|
||||
return await runKeysCli(rest, {
|
||||
out: s => process.stdout.write(s),
|
||||
err: s => process.stderr.write(s),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Usage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const USAGE = `OLP operator CLI — Phase 4 (ADR 0010)
|
||||
|
||||
Usage:
|
||||
olp <subcommand> [args] [--json] [--proxy-url=<url>] [--olp-home=<path>]
|
||||
|
||||
Subcommands:
|
||||
status GET /v0/management/status (owner-only)
|
||||
health GET /health
|
||||
usage GET /v0/management/dashboard-data (owner-only)
|
||||
models GET /v1/models
|
||||
cache GET /cache/stats (owner-only)
|
||||
providers list providers (registry + config providers.enabled)
|
||||
chain show [<model>] print routing.chains from ~/.olp/config.json
|
||||
logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson
|
||||
restart launchctl (macOS) / systemctl --user (Linux)
|
||||
doctor [--check X] run diagnostic checks (id, category, or prefix filter)
|
||||
keys [args ...] delegate to bin/olp-keys.mjs
|
||||
help print this message
|
||||
|
||||
Global flags:
|
||||
--json emit raw JSON (silences human-readable formatting)
|
||||
--proxy-url=<url> override resolved proxy URL
|
||||
--olp-home=<path> override ~/.olp
|
||||
|
||||
Env:
|
||||
OLP_PROXY_URL full URL (overrides OLP_PORT)
|
||||
OLP_PORT port for default URL (default: 4567)
|
||||
OLP_API_KEY Bearer token for the proxy
|
||||
OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4)
|
||||
OLP_HOME ~/.olp override
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 bad usage / unknown subcommand
|
||||
2 network or HTTP error (4xx/5xx)
|
||||
3 auth missing / forbidden`;
|
||||
|
||||
// ── runCli (testable entry) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the CLI with explicit argv + IO streams. Returns the exit code (no
|
||||
* process.exit). Exported for tests.
|
||||
*
|
||||
* @param {string[]} argv args after the script name
|
||||
* @param {object} [opts]
|
||||
* @param {(s: string) => void} [opts.out]
|
||||
* @param {(s: string) => void} [opts.err]
|
||||
* @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runCli(argv, opts = {}) {
|
||||
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
|
||||
const io = makeIO({ ...opts, json: false });
|
||||
io.log(USAGE);
|
||||
return argv.length === 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = argv;
|
||||
|
||||
// `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs.
|
||||
if (subcommand === 'keys') {
|
||||
const io = makeIO({ ...opts, json: false });
|
||||
return await cmdKeys(rest, io);
|
||||
}
|
||||
|
||||
const { positional, flags } = parseArgv(rest);
|
||||
const json = flags.json === true;
|
||||
const io = makeIO({ ...opts, json });
|
||||
|
||||
switch (subcommand) {
|
||||
case 'status': return await cmdStatus(flags, io);
|
||||
case 'health': return await cmdHealth(flags, io);
|
||||
case 'usage': return await cmdUsage(flags, io);
|
||||
case 'models': return await cmdModels(flags, io);
|
||||
case 'cache': return await cmdCache(flags, io);
|
||||
case 'providers': return cmdProviders(flags, io);
|
||||
case 'chain': {
|
||||
// `olp chain show [model]`
|
||||
const sub = positional[0];
|
||||
if (sub !== 'show') {
|
||||
io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`);
|
||||
return 1;
|
||||
}
|
||||
return cmdChainShow(positional.slice(1), flags, io);
|
||||
}
|
||||
case 'logs': return await cmdLogs(positional, flags, io);
|
||||
case 'restart': return await cmdRestart(flags, io);
|
||||
case 'doctor': return await cmdDoctor(flags, io);
|
||||
default:
|
||||
io.errln(`Error: unknown subcommand "${subcommand}".`);
|
||||
io.errln(USAGE);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main guard ────────────────────────────────────────────────────────────
|
||||
|
||||
function _isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
if (_isMain()) {
|
||||
runCli(process.argv.slice(2))
|
||||
.then(code => process.exit(code))
|
||||
.catch(e => {
|
||||
process.stderr.write(`Fatal: ${e?.stack ?? e}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
+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>
|
||||
@@ -40,7 +40,7 @@ What OLP **does NOT inherit** from ADR 0005 (and where ADR 0005's reasoning does
|
||||
- ADR 0005's separate-project recommendation came with two qualifiers that OLP rejects: "BYOK from day one" and "no `cli.js` spawn." Both qualifiers were appropriate for the *commercial* path ADR 0005 was contemplating. OLP is not commercial — it is personal- and family-scale, shares the maintainer's own subscription quota across family clients, and explicitly spawns provider CLIs (it is precisely the spawn-binary architecture that delivers the "subscription quota maximization" value proposition spec §1 names).
|
||||
- OLP is therefore not the commercial pivot ADR 0005 endorsed. It is a personal-use re-architecture of the proxy-CLI pattern, which ADR 0005 did not contemplate. The supersession is honest about this gap.
|
||||
|
||||
OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).
|
||||
OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. ~~The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).~~ **Amended at D60 (2026-05-26, ADR 0010 Phase 4 charter):** the port-conflict assumption is lifted. OLP's default port moved `3456 → 4567` at v0.4.0 so OCP (which stays on 3456) and OLP can co-host on the same machine during a transition window. Launchd label collision **will be** avoided via `dev.olp.proxy` (OLP plist generation lands at Phase 4 close per ADR 0010 D64–D70; not on disk at D60) vs `dev.ocp.proxy` (OCP, already shipped). Credentials remain per-project (`~/.ocp/` vs `~/.olp/`). Co-host is explicit-opt-in, not the recommended steady state.
|
||||
|
||||
OCP ADR 0005 receives a header amendment on merge of this ADR: *"Superseded in part by OLP — see https://github.com/dtzp555-max/olp ADR 0001 for the narrow scope of the supersession (single-provider-sufficiency premise only; ADR 0005's commercial / BYOK / no-spawn recommendations are not adopted)."* The body of ADR 0005 is otherwise untouched. Future readers should see the original reasoning intact and the supersession marker scoped explicitly.
|
||||
|
||||
|
||||
@@ -7,7 +7,26 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
|
||||
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
|
||||
|
||||
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
|
||||
- **Change — add to Provider contract:**
|
||||
- Introduce **OPTIONAL** `doctorChecks()` returning `DoctorCheck[]` where each `DoctorCheck` has the shape:
|
||||
- `id: string` — unique per check, conventionally `<provider>.<probe-name>` (e.g. `anthropic.cli_available`, `anthropic.oauth_token_present`).
|
||||
- `category: 'provider'` — fixed for plugin-contributed checks. The framework reserves `'server'`, `'auth'`, `'config'`, `'system'` for built-in checks.
|
||||
- `async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }` — runs the probe. `status: 'fail'` makes `olp doctor` exit non-zero and contributes to the `kind: fix_provider` discriminator; `evidence.fix_commands[]` is concatenated into `next_action.ai_executable[]` and `evidence.human_steps[]` into `next_action.human_required[]`.
|
||||
- **Backwards compatibility:** Plugins that omit `doctorChecks()` contribute zero provider checks. Their healthCheck() return value continues to flow through `/health.providers.status.<name>` exactly as today. No existing plugin behaviour changes; no existing test breaks. `validateProvider` in `lib/providers/base.mjs` is updated to type-check `doctorChecks` only when present (must be a function); absence is allowed.
|
||||
- **What `doctorChecks()` is for vs. what `healthCheck()` is for:**
|
||||
- `healthCheck()` answers "is this provider currently usable?" — checked at the request-execution layer; output feeds `/health` and per-request retry decisions.
|
||||
- `doctorChecks()` answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feeds `olp doctor` + the `next_action.ai_executable[]` repair templates that a downstream AI agent can paste-and-run.
|
||||
- **Suggested probe set (per plugin):**
|
||||
- `<provider>.cli_available` — spawn `<bin> --version` with short timeout (≤3s); fail → fix_commands include install instruction.
|
||||
- `<provider>.<auth-artifact>_present` — check whether the auth file / env var the plugin's `readAuthArtifact()` reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be in `ai_executable[]`).
|
||||
- **Authority:** ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed — `doctorChecks()` is an internal contract field. Implementation lands in D67 (this PR): `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, `lib/providers/mistral.mjs` each gain a `doctorChecks()` method covering `cli_available` + `<auth-artifact>_present`.
|
||||
- **Tests:** Suite 32 (`bin/olp.mjs` CLI smoke) and Suite 33 (`olp doctor` framework) in `test-features.mjs` cover the contract amendment. Suite 33 specifically asserts: (a) a plugin without `doctorChecks()` contributes no provider checks (default behaviour), (b) a plugin with a failing `doctorChecks()` probe triggers `kind: fix_provider` and propagates its `evidence.fix_commands[]` into `next_action.ai_executable[]`, (c) all-passing checks yield `kind: noop`.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
|
||||
|
||||
### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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 4567 since v0.4.0 / D60; 3456 pre-v0.4.0) 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).
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR 0010 — Phase 4 Charter: Operator + Client UX
|
||||
|
||||
**Status:** Accepted (Phase 4 open as of 2026-05-26)
|
||||
**Date:** 2026-05-26
|
||||
**D-day:** D60 (charter + default port change)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Phases 1 — 3 shipped OLP's structural core: HTTP entry surface, IR, provider plugins, fallback engine, content-addressed cache (including streaming-path singleflight at D57+D58 → v0.3.2), multi-key auth + audit ndjson + daily rotation, owner-only management endpoints + dashboard. v1.x roadmap items #1 / #2 / #4 / #7 are closed. Items #3 / #5 / #6 remain trigger-gated.
|
||||
|
||||
Two complementary brainstorm passes (2026-05-26) — a comprehensive OCP feature audit + a multi-provider proxy / IDE integration prior-art survey — converged on a clear gap: **OLP's operator and client surfaces are 0% inherited from OCP**. Today OLP has `bin/olp-keys` and `bin/olp-audit-rotate` as the entire operator CLI, no `olp doctor` / no `olp-connect`, no Telegram/Discord integration, no SSE heartbeat for long-running streams behind reverse proxies. Family members get OLP API keys via out-of-band paste, point their IDEs at OLP via the README's one-line example, and discover failure modes via curl. OCP's UX worked because of a load-bearing combination: README `paste-this-prompt-to-Claude-Code` instructions + machine-readable `ocp doctor next_action.ai_executable[]` + `ocp-connect` zero-config LAN setup + `/health.anonymousKey` self-advertising token + `/ocp` Telegram slash commands. **Phase 4 brings these forward as OLP-native primitives.**
|
||||
|
||||
A separate strategic decision — should OLP add `/v1/messages` (Anthropic-shape entry surface) for Claude Code support — was considered and **rejected for Phase 4** (see § "Out of Phase 4 scope" below). The decision is recorded with an explicit re-open trigger.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 4 scope is **Operator + Client UX**. The phase opens 2026-05-26 with D60 (this charter + default port change). Phase 4 close ships v0.4.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
|
||||
|
||||
### In scope — Phase 4 D-day plan (~13 D-days)
|
||||
|
||||
| D-day | Deliverable | Authority | Estimate |
|
||||
|---|---|---|---|
|
||||
| **D60** | Default port `3456 → 4567` + this ADR 0010 charter + README / CHANGELOG / ADR 0001 + ADR 0008 amendments | This charter | 0.5d |
|
||||
| **D61 — D63** | SSE heartbeat (opt-in via `streaming.heartbeat_interval_ms` config; eager-headers-post-spawn; `X-Accel-Buffering: no` constant) + `recentErrors[20]` ring buffer + `/status` combined endpoint | Port OCP `server.mjs:660-685` + `301-358` + `1151-1188`; OCP `docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md` | 2.5d |
|
||||
| **D64 — D67** | `olp` Node-based CLI scaffold (subcommands `status / health / usage / models / logs / cache / providers / chain show / restart / doctor`) + `olp doctor` machine-readable `next_action.ai_executable[]` framework + one fix-template per shipped provider plugin | Port OCP `ocp` bash wrapper (translated to Node — bash dep on python3 is a known fragile point) + OCP `scripts/doctor.mjs` framework | 4d |
|
||||
| **D68 — D70** | `olp-connect <ip>` client-side IDE auto-config (Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw detection) + `/health.anonymousKey` field (opt-in via `auth.advertise_anonymous_key` config; default off) + ADR 0011 (anonymous-key deployment-context limits — trusted-LAN-only invariant explicit) | Port OCP `ocp-connect` + `server.mjs:1454,1488` | 3d |
|
||||
| **D71 — D73** | `olp-plugin/` (OpenClaw gateway plugin for `/olp` Telegram/Discord slash commands; subcommand parity with `olp` CLI minus mutations) + `docs/integrations/{continue.md,cline.md,cursor.md,aider.md,claude-code.md,openclaw.md}` IDE setup docs | Port OCP `ocp-plugin/index.js`; cross-ref Prior-Art § 3 + § 4 | 3d |
|
||||
| **close** | v0.4.0 release PR — `package.json` bump, CHANGELOG promotion, `release_kit.phase_rolling_mode` advance to Phase 5 pre-release identifier | `CLAUDE.md release_kit overlay` | maintainer-triggered |
|
||||
|
||||
### Out of Phase 4 scope (with explicit triggers)
|
||||
|
||||
#### `/v1/messages` — Anthropic-shape entry surface
|
||||
|
||||
**Status:** Deferred. Re-enable strictly gated on ADR 0009 P0 success.
|
||||
|
||||
**Value matrix (decisive):**
|
||||
|
||||
| Scenario | Without `/v1/messages` | With `/v1/messages` |
|
||||
|---|---|---|
|
||||
| Maintainer's own Claude Code usage | Direct via Anthropic OAuth → subscription (today) or Agent SDK pool (post-2026-06-15) | Same — maintainer never routes own CC through OLP per stated workflow |
|
||||
| Family member wanting CC access | Not supported (OAuth is full-account; OLP CLI tokens are scoped) | CC via `ANTHROPIC_BASE_URL=http://olp:4567` + `olp_*` token |
|
||||
| **P0 succeeds** (ADR 0009 interactive-mode bills as subscription) | OpenAI-shape IDE clients (Cline/Continue/Cursor) all benefit automatically via OLP's anthropic plugin | CC users additionally benefit; both subscription-billed |
|
||||
| **P0 fails** (interactive-mode bills as Agent SDK same as `-p`) | OpenAI-shape clients still work; no billing change | CC users get same billing as direct OAuth; **fallback to codex/mistral degrades Anthropic-specific features (tool_use schema mismatch / cache_control drop / computer_use no-op / thinking-block drop)** more severely than OpenAI-shape clients which speak the multi-provider lingua franca |
|
||||
|
||||
**Rationale.** Under P0 failure, `/v1/messages` provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients (because OpenAI tool schema is the cross-provider standard). The security benefit (no OAuth exposure) is equally achievable via Cline/Continue/Cursor. **Net non-positive under P0 failure.**
|
||||
|
||||
**Re-open condition.** (a) ADR 0009 P0 confirms interactive-mode billing classification as subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens Phase 5 "Anthropic-shape hub" scope with the name of at least one family member who wants CC access. If only (a) fires without (b), `/v1/messages` is reconsidered at the start of whichever phase covers it but is not auto-opened.
|
||||
|
||||
**README posture (Phase 4).** README § Supported Clients explicitly lists OpenAI-compatible clients (Cline, Continue.dev, Cursor, Aider, OpenClaw bots). Claude Code is listed as **Not supported as an OLP client**, with the explicit alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility). README links to this ADR for the reasoning.
|
||||
|
||||
#### Other deferred items
|
||||
|
||||
- **v1.x roadmap #3 (soft trigger reactivation)**, **#5 (provider `cacheKeyFields` mask)**, **#6 (streaming SPAWN_FAILED salvage)** — trigger conditions per `docs/v1x-roadmap.md` have not fired. Not in Phase 4.
|
||||
- **Anthropic / codex billing audits** — date-gated (`anthropic.mjs:53, 416, 441` say 2026-06-16; `codex.mjs:572` post-D7 E2E audit). Not in Phase 4.
|
||||
- **`context_window_exceeded` fallback trigger** (LiteLLM prior-art) — small ADR amendment + trigger taxonomy add; opportunistically in Phase 5 unless trigger fires sooner.
|
||||
- **`X-OLP-Cost-USD` per-request response header** — depends on provider-cost weights table (Phase 5 prerequisite).
|
||||
- **per-(provider, model) live stats Map** (replacing audit-query scan for dashboard 30s poll) — current scan latency adequate; Phase 5+.
|
||||
- **OpenTelemetry GenAI span emission** — `npm` dep + ~150 LOC; family-scale ROI marginal. Phase 6+ unless Langfuse self-host requested.
|
||||
- **Intent-based routing**, **stackable transformer plugin model** — explicit non-goals per Prior-Art § 8 anti-patterns.
|
||||
|
||||
### Opportunistic Phase 4 micro-additions (not blocking)
|
||||
|
||||
Items small enough to land alongside a planned D-day without scope creep, if encountered:
|
||||
|
||||
- Env-var deny-list before provider plugin `spawn` (per OCP `server.mjs:531-534`; each plugin declares its own list)
|
||||
- 5 MB request body cap with HTTP 413 (per OCP `server.mjs:1270,1278-1281`)
|
||||
- Error-response path-sanitization (per OCP `server.mjs:1395`)
|
||||
- Stable node-path resolution in launchd plist (Homebrew `/Cellar/<ver>/` → `/opt/` rewrite; per OCP `setup.mjs:344-351`)
|
||||
- Legacy model alias resolution in `models-registry.json` (`aliases:` field; per OCP `legacyAliases`)
|
||||
|
||||
### Exit gate — v0.4.0 close criteria
|
||||
|
||||
1. D60 — D73 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
|
||||
2. CI green on every D-day merge commit and on the v0.4.0 release commit head.
|
||||
3. README § Operator CLI + § IDE Setup + § Telegram/Discord Usage sections present.
|
||||
4. ADR 0010 (this charter) + ADR 0011 (anonymous-key deployment-context limits) on disk.
|
||||
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.4.0 — <date>"` with D60 — D73 entries.
|
||||
6. `package.json` bumped to `0.4.0`.
|
||||
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 4 → Phase 5`; `current_pre_release_identifier` advances `0.4.0-phase4 → 0.5.0-phase5`.
|
||||
8. Standing autopilot grant covers D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered.
|
||||
|
||||
---
|
||||
|
||||
## Default port change (D60 specific)
|
||||
|
||||
The default `OLP_PORT` value moves `3456 → 4567` at this D-day. Rationale:
|
||||
|
||||
- OCP defaults to 3456 and the maintainer's existing OCP installs stay on 3456 indefinitely.
|
||||
- A standard `olp` install on the same host without overriding `OLP_PORT` collides at bind time.
|
||||
- Setting `OLP_PORT=4567` as the default makes co-host the recommended steady state during the migration window (and beyond — there is no enforced deprecation of OCP).
|
||||
- Existing OLP deployments wanting the pre-D60 default can set `OLP_PORT=3456` in the launchd plist / shell env.
|
||||
|
||||
**Tested invariants preserved by the port change:**
|
||||
|
||||
- All `test-features.mjs` suites use `port: 0` (ephemeral assigned port) — no test depends on the default value. Verified via `grep -nE '\\b3456\\b' test-features.mjs` returning empty.
|
||||
- All cache / fallback / provider plugin code is port-agnostic.
|
||||
- Dashboard 30s poll uses relative paths — no port change required in `dashboard.html`.
|
||||
- `/v0/management/*` endpoints use relative paths — no client-side update required.
|
||||
|
||||
**Files amended at D60:**
|
||||
|
||||
- `server.mjs:17` — env-var doc comment
|
||||
- `server.mjs:74` — default value
|
||||
- `README.md` quick start + Environment Variables table + Migration from OCP § note
|
||||
- `docs/adr/0001-project-founding.md` § "Decision" paragraph about port conflict (struck and amended)
|
||||
- `docs/adr/0008-dashboard-and-audit-query.md` § 6.6 port reference
|
||||
- `CHANGELOG.md` Unreleased entry
|
||||
- This ADR
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- Family member onboarding goes from "maintainer texts API key + edits IDE config" to `curl -fsSL .../olp-connect | bash -s -- <ip>`.
|
||||
- `paste-this-prompt-to-Claude-Code` self-installation pattern unlocks AI-driven setup / upgrade / repair, eliminating the maintainer's Tier-1 support role.
|
||||
- Long-reasoning streams behind nginx / Cloudflare / Tailscale Funnel no longer 502 at 60s idle.
|
||||
- `/olp` Telegram slash commands enable "is OLP up?" / "show usage" / "rotate key" from anywhere with chat access.
|
||||
- OCP and OLP co-host on the same workstation, lowering the maintainer's cost of running both.
|
||||
|
||||
**Negative.**
|
||||
|
||||
- Phase 4 is the first phase whose scope is primarily about *operator experience* rather than functional capability. The work doesn't unlock new requests OLP can serve; it makes OLP's existing capability survive contact with real users.
|
||||
- The `olp-connect` IDE auto-detect logic accumulates IDE-specific quirks (Cline base-URL UI regressions per their issue #7128; Cursor's malformed-request-when-OpenRouter behavior; etc.). Maintenance burden grows.
|
||||
- README size grows substantially with Operator CLI + IDE Setup + Telegram/Discord sections. Discoverability of the existing technical reference (ADRs, environment variables) may degrade unless the navigation is refactored.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- Phase 4 deliberately spends 0 D-days on `/v1/messages`. If ADR 0009 P0 succeeds in Q3 2026, Phase 5 "Anthropic-shape hub" becomes the natural next phase, with the prerequisite IR work that Phase 4 surfaces (every IDE doc page is a test of which IR fields actually flow through). If P0 fails, `/v1/messages` shelves indefinitely and the README simply documents CC as out-of-scope.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Phase 4 = `/v1/messages` first, operator UX later.** Rejected. The brainstorm matrix demonstrated `/v1/messages` is value-positive only if ADR 0009 P0 succeeds, and operator UX gains accrue regardless. Building speculative infrastructure ahead of P0 risks 5-7 D-days of work shelving.
|
||||
2. **Phase 4 = operator + client UX + `/v1/messages` together (full kitchen sink).** Rejected. ~20 D-days lengthens the Phase 4 close window unnecessarily; the natural review chunks blur; maintainer review fatigue is real.
|
||||
3. **Phase 4 = just D60 + opportunistic SSE heartbeat, no CLI / no plugin / no docs bundle.** Rejected. Each of the operator-UX items individually has small ROI; the value compounds when they ship together (CLI surfaces data → `/status` exposes shape → Telegram plugin renders → IDE docs reference → `olp-connect` automates). Splitting them across phases loses the compounding.
|
||||
4. **Defer Phase 4 entirely; jump to Phase 5 Anthropic-shape hub when P0 lands.** Rejected. Operator UX is needed now (this session is itself evidence — the maintainer spent ~30 minutes confirming OCP feature inheritance because there's no `olp doctor` answer). Waiting for P0 stalls progress on independently-valuable work.
|
||||
|
||||
---
|
||||
|
||||
## Authority
|
||||
|
||||
- `docs/v1x-roadmap.md` — Phase 4 was named as the canonical destination for the post-cleanup batch since v0.3.0 close.
|
||||
- `CLAUDE.md release_kit.phase_rolling_mode` — `current_phase: Phase 4` already; this charter formalizes the contents.
|
||||
- OCP comprehensive feature audit (2026-05-26 subagent output, summarized in `~/.cc-rules/memory/auto/MEMORY.md` and in this session's transcript).
|
||||
- Multi-provider proxy / IDE integration prior-art survey (2026-05-26 subagent output).
|
||||
- ADR 0009 (Anthropic interactive-mode path placeholder) — establishes the gate for `/v1/messages` re-consideration.
|
||||
- ADR 0001 (project founding) — § "Decision" paragraph about port conflict, amended at this D-day.
|
||||
- ADR 0008 (dashboard + audit query) — § 6.6 default-port reference, amended at this D-day.
|
||||
- `~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` — standing autopilot grant covering D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered per `release_kit.phase_close_trigger`.
|
||||
|
||||
---
|
||||
|
||||
## Procedural mechanism
|
||||
|
||||
CC 开发铁律 v1.6 § 5.5 (release-kit overlay drives Phase boundaries) + § 10 (independent reviewer on every implementation D-day) + § 11 (minimum reviewable unit per PR — this charter ships as D60 PR alongside the default port change because both are governance-class and small).
|
||||
@@ -0,0 +1,340 @@
|
||||
# ADR 0011 — Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant)
|
||||
|
||||
**Status:** Accepted (2026-05-26)
|
||||
**Date:** 2026-05-26
|
||||
**D-day:** D70 (lands alongside D68 `olp-connect` + D69 `/health.anonymousKey`)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0010 § Phase 4 D68-D70 charter scoped a three-deliverable bundle:
|
||||
|
||||
- **D68** `olp-connect <ip>` — client-side bash script that auto-configures a
|
||||
family member's machine to point at a remote OLP instance, including IDE
|
||||
detection (Cline / Continue.dev / Cursor / Aider / OpenClaw) and rc-file +
|
||||
system-level env var writes.
|
||||
- **D69** `/health.anonymousKey` field — opt-in (`auth.advertise_anonymous_key:
|
||||
true` in `~/.olp/config.json`; default `false`) surface that emits the
|
||||
plaintext of a designated guest-tier key so `olp-connect` can pick it up
|
||||
with zero-config (no out-of-band token paste).
|
||||
- **D70** this ADR — codifies the deployment-context limits that make D69
|
||||
safe.
|
||||
|
||||
The D69 mechanism is a deliberate port of OCP's clever `PROXY_ANONYMOUS_KEY` +
|
||||
`/health.anonymousKey` pattern (OCP `server.mjs:148, 1454, 1488, 1555`,
|
||||
shipped 2026-04 under OCP issue #12 § 14 Path A) which made family-member
|
||||
onboarding go from "maintainer texts API key + edits IDE config" to
|
||||
`curl -fsSL .../ocp-connect | bash -s -- <ip>`. The OCP pattern works on
|
||||
trusted family LAN deployments; it would catastrophically fail on a public
|
||||
internet deployment. ADR 0011 makes the trust assumption explicit before OLP
|
||||
inherits the pattern.
|
||||
|
||||
The ADR also pins three implementation details that are NOT obvious from
|
||||
reading the D69 patch alone:
|
||||
|
||||
1. The plaintext token must live on disk SOMEWHERE for the server to surface
|
||||
it. ADR 0007 § 5 + § 6.2 explicitly forbid plaintext storage in the
|
||||
default manifest. D69 introduces an explicit **opt-in** `plaintext_advertise`
|
||||
manifest field for ONLY the advertised key — every other key retains the
|
||||
ADR 0007 § 5 hash-only contract.
|
||||
2. The advertised key is **guest-tier**, not owner-tier. Owner-tier
|
||||
advertisement is rejected at keygen time AND at config-load time —
|
||||
exposing the owner identity unauthenticated would grant any LAN caller
|
||||
`/health` full payload, `/v0/management/*` mutating access, and
|
||||
`X-OLP-Fallback-Detail` visibility — the exact inverse of the advertise
|
||||
key's intent (a low-privilege zero-config tier).
|
||||
3. Three prerequisites MUST hold simultaneously for `/health.anonymousKey`
|
||||
to be emitted; missing any one is logged at startup but the server still
|
||||
boots — graceful-degrade rather than refuse-to-start.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### Three-prerequisite gate (server-side)
|
||||
|
||||
`/health` emits the `anonymousKey` field if and only if ALL THREE hold:
|
||||
|
||||
1. `auth.advertise_anonymous_key === true` in `~/.olp/config.json` (default
|
||||
`false` — opt-in).
|
||||
2. `auth.allow_anonymous === true` (the anonymous tier must be reachable for
|
||||
the advertised key to be meaningful to zero-config callers; advertising a
|
||||
key into a deployment that rejects anonymous requests is incoherent).
|
||||
3. At least one active (`revoked_at === null`) manifest under `~/.olp/keys/`
|
||||
carries a non-empty `plaintext_advertise: "olp_..."` field.
|
||||
|
||||
When prerequisite (1) holds but (2) or (3) fails, the server logs a
|
||||
startup warn (`anonymous_key_advertised_but_denied` or
|
||||
`anonymous_key_advertised_but_no_anonymous_key_exists`) but starts normally
|
||||
and simply omits the field from `/health` responses.
|
||||
|
||||
### Plaintext storage mechanism (`plaintext_advertise` manifest field)
|
||||
|
||||
ADR 0007 § 5 forbids plaintext storage anywhere. D69 introduces a single,
|
||||
**explicitly opt-in** exception: the manifest of the designated advertised
|
||||
key gains a `plaintext_advertise` field whose value is the plaintext token.
|
||||
|
||||
This field is written ONLY when the operator runs:
|
||||
|
||||
```
|
||||
olp-keys keygen --anonymous --advertise
|
||||
```
|
||||
|
||||
(or `--advertise` alone on a guest-tier `keygen` invocation; `--anonymous`
|
||||
is a friendly shorthand for `--tier=guest --name=anonymous`). The keygen
|
||||
command surfaces an explicit `WARNING` to stderr at creation time:
|
||||
|
||||
```
|
||||
WARNING: this key's plaintext is now stored on disk + will be exposed via
|
||||
/health.anonymousKey when auth.advertise_anonymous_key=true AND
|
||||
auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.
|
||||
```
|
||||
|
||||
Every other key (every existing key, and every newly-created key without
|
||||
`--advertise`) retains the ADR 0007 § 5 hash-only contract — `manifest.json`
|
||||
contains `token_hash` and NEVER `plaintext_advertise`.
|
||||
|
||||
**Schema-version note (D69 reviewer P2-2).** This adds a new optional field
|
||||
to the manifest. Per ADR 0007 § 4 ("Increment `schema_version` on any
|
||||
non-additive change"), additive optional fields do NOT require a
|
||||
`schema_version` bump — older parsers ignore unknown fields per the same
|
||||
section's forward-compat rule. The manifest stays at `schema_version: 1`.
|
||||
Documented here so a future archaeologist asking "why didn't D69 bump
|
||||
`schema_version`?" has a one-line answer.
|
||||
|
||||
**`listKeys()` redaction (D69 reviewer P2-1).** `lib/keys.mjs listKeys()`
|
||||
strips BOTH `token_hash` AND `plaintext_advertise` from its return value.
|
||||
Callers wanting the advertised plaintext for the `/health` publication
|
||||
path MUST go through `findAdvertisedKey()` — the only sanctioned read
|
||||
site. This protects against a future caller of `listKeys()` accidentally
|
||||
emitting the plaintext into logs / HTTP responses / dashboards.
|
||||
|
||||
### Tier restriction (guest only)
|
||||
|
||||
`createKey()` rejects `plaintext_advertise: true` for `owner_tier: 'owner'`
|
||||
with the error `createKey: plaintext_advertise requires owner_tier="guest"`.
|
||||
The CLI also rejects `--owner --advertise` with a clear error pointing at
|
||||
this ADR.
|
||||
|
||||
Rationale: owner-tier confers `/health` full payload visibility,
|
||||
`/v0/management/*` mutating access, and `X-OLP-Fallback-Detail` header
|
||||
visibility. Advertising owner-tier plaintext unauthenticated would let any
|
||||
LAN caller assume the owner identity — the exact opposite of the design
|
||||
intent.
|
||||
|
||||
### Trusted-LAN deployment invariant
|
||||
|
||||
`auth.advertise_anonymous_key: true` is permitted ONLY when the OLP server
|
||||
is bound to a trust-equivalent address space:
|
||||
|
||||
| Tier | Address space | Permitted? |
|
||||
|------|---------------|------------|
|
||||
| Loopback | `127.0.0.0/8` | yes |
|
||||
| RFC 1918 LAN | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | yes |
|
||||
| Tailnet | `100.64.0.0/10` (CGNAT range used by Tailscale) | yes |
|
||||
| Localhost domains | `localhost`, `*.local`, `*.internal` | yes |
|
||||
| Public internet | any routable IPv4/IPv6 outside the above | NO |
|
||||
|
||||
This is a **soft constraint** at v0.4.0 — OLP does not enforce IP-allowlist
|
||||
or BIND_ADDRESS inspection. The constraint is documented here, surfaced in
|
||||
README § "Anonymous-key advertise mode (trusted-LAN-only)", and warned-but-
|
||||
not-blocked at server startup when `auth.advertise_anonymous_key=true` and
|
||||
the bind address looks public.
|
||||
|
||||
Hard enforcement (refuse to start when bind is public + advertise enabled)
|
||||
is deferred. The maintainer's deployments are LAN-only, the family-scale
|
||||
audience cannot tolerate a startup-refuse mode that bricks the proxy on
|
||||
ambiguous network topology (e.g., TLS-fronted private network where the
|
||||
underlying bind IP IS public but the network itself is trusted), and the
|
||||
trade-off in ADR 0010 explicitly accepted operator-discretion gates for
|
||||
soft constraints of this class.
|
||||
|
||||
---
|
||||
|
||||
## Threat model
|
||||
|
||||
The advertised anonymous key is **public** within the boundary of "anyone
|
||||
who can reach `GET /health`." Anyone within that boundary can read the
|
||||
plaintext from `/health.anonymousKey` and use it for `/v1/chat/completions`,
|
||||
`/v1/models`, etc.
|
||||
|
||||
| Deployment | Boundary | Acceptable? |
|
||||
|------------|----------|-------------|
|
||||
| Mac mini + Tailscale, only family devices on tailnet | family devices | YES |
|
||||
| Home LAN with no guest WiFi, no port-forward | household + neighbors-within-WiFi-range | YES (within risk tolerance) |
|
||||
| Home LAN with guest WiFi joined to same VLAN as proxy | EVERYONE who visits and connects to guest WiFi | borderline; treat with caution |
|
||||
| Coffee shop / open WiFi | EVERYONE physically present | NO |
|
||||
| Public internet via Cloudflare Tunnel / port-forward / VPS | EVERYONE on the internet | NO — instant compromise |
|
||||
|
||||
The capability gain for an attacker who reads `/health.anonymousKey` is
|
||||
**equal to the capability the operator deliberately granted the
|
||||
anonymous-tier key**:
|
||||
|
||||
- `providers_enabled` (when `'*'`, the attacker can dispatch any provider —
|
||||
burning the operator's subscription quotas).
|
||||
- `/v1/chat/completions` access (LLM use under the operator's billing).
|
||||
- Cache pollution under `__anonymous__` namespace (per ADR 0007 § 7.1; the
|
||||
advertised guest key uses its own `<key-id>` namespace — but anonymous
|
||||
callers who DON'T present the key use `__anonymous__`).
|
||||
|
||||
What the attacker does NOT get:
|
||||
|
||||
- `/health` full payload — gated to `owner_tier === 'owner'` (ADR 0007 § 7.1).
|
||||
- `/v0/management/*` mutating endpoints — gated to owner (ADR 0008 § 7).
|
||||
- `X-OLP-Fallback-Detail` header — `'owner_only'` policy default (ADR 0007 § 7.2).
|
||||
- The owner key's plaintext (which is never stored anywhere; only its
|
||||
`token_hash` is on disk per ADR 0007 § 5).
|
||||
|
||||
The "burn the operator's subscription quotas" failure mode is bounded by
|
||||
the per-provider quota limits AND the maintainer's monitoring (`/health`
|
||||
owner-view shows quota status per provider; `/v0/management/audit` shows
|
||||
per-key usage). Detection is fast; the question is how much quota the
|
||||
attacker can burn between compromise and key revocation.
|
||||
|
||||
---
|
||||
|
||||
## `olp-connect` integration (D68 client-side)
|
||||
|
||||
`bin/olp-connect <ip>` queries `GET /health` as its first action. If the
|
||||
response contains `anonymousKey: "olp_..."`, the script uses that value
|
||||
silently for the rest of the run (printing a one-line `Using server-
|
||||
advertised anonymous key: olp_...XXXX` notice + a pointer to this ADR).
|
||||
This is what makes `olp-connect <ip>` a true zero-config command — no
|
||||
out-of-band token paste needed.
|
||||
|
||||
If `anonymousKey` is absent (the default, when `auth.advertise_anonymous_key`
|
||||
is false), the script falls back to interactive prompt or `--key` flag.
|
||||
|
||||
`olp-connect` does NOT perform any of the trusted-LAN soft-checks itself —
|
||||
it trusts that an operator who set `auth.advertise_anonymous_key: true`
|
||||
knows their deployment context. The script does, however, document the
|
||||
trade-off in its `--help` output and prints the ADR 0011 reference
|
||||
alongside the "using server-advertised key" notice.
|
||||
|
||||
---
|
||||
|
||||
## Re-evaluation triggers
|
||||
|
||||
Re-open this ADR when ANY of the following fires:
|
||||
|
||||
1. OLP gains a "expose to public internet" deployment mode in the README
|
||||
(e.g., Cloudflare Tunnel guidance, ngrok recipe). At that point the
|
||||
soft-constraint MUST become a hard constraint (bind-address inspection
|
||||
at startup, refusal to enable `advertise_anonymous_key` when bind is
|
||||
public — likely with a separate `OLP_TRUSTED_PUBLIC_OVERRIDE=1` env
|
||||
escape hatch for operators who run their own TLS termination).
|
||||
2. The OCP `/health.anonymousKey` model is found to have caused a
|
||||
real-world quota-burn incident; that learning amends this ADR.
|
||||
3. Phase 5 introduces multi-tenant SaaS-like deployments (currently
|
||||
non-goal per ADR 0001); the entire family-scale assumption is
|
||||
re-examined.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- Family-member onboarding becomes a single command: `olp-connect <ip>`. No
|
||||
out-of-band token paste. No "wait, what's the API key?" friction loop.
|
||||
- The trust trade-off is now an explicit, single-knob config decision, not
|
||||
an implicit consequence of OCP-pattern inheritance.
|
||||
- The `plaintext_advertise` field is a single auditable on-disk surface —
|
||||
`grep plaintext_advertise ~/.olp/keys/*/manifest.json` answers "which key
|
||||
is advertised?" definitively, and an operator who wants to disable the
|
||||
feature can simply revoke that key.
|
||||
- Owner-tier advertisement is impossible (both at keygen and at config
|
||||
load), eliminating an entire class of foot-gun.
|
||||
|
||||
**Negative.**
|
||||
|
||||
- ADR 0007 § 5's "no plaintext on disk, ever" property is weakened to "no
|
||||
plaintext on disk except for ONE explicitly-opted-in field on ONE key."
|
||||
The exception is narrow and audit-grep-able but the property is no
|
||||
longer absolute.
|
||||
- Operators who enable advertise mode then move the deployment from LAN to
|
||||
public internet (e.g., add a Cloudflare Tunnel without revisiting the
|
||||
config) silently invert the threat model. The startup warn for "public
|
||||
bind detected" does not currently fire (soft constraint per § "Trusted-
|
||||
LAN deployment invariant" above).
|
||||
- The OCP precedent shows operators sometimes share `olp-connect <ip>`
|
||||
invocations in chat / docs that include their IP; an LLM training corpus
|
||||
could harvest these IPs. The advertised key is only useful while the
|
||||
network reaches the IP, but the IP-disclosure surface grows.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- The plaintext storage is per-key, not global. Revoking the advertised key
|
||||
removes the plaintext exposure within one filesystem write (the manifest
|
||||
stays on disk for audit attribution per ADR 0007 § 6.1, but `revoked_at`
|
||||
becomes non-null and `findAdvertisedKey()` skips revoked manifests).
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Store plaintext in `config.json` directly.** Rejected. Mixes secrets
|
||||
with operational config; complicates git-crypt boundary; loses the
|
||||
per-key revocation path (you'd have to edit JSON to "revoke" the
|
||||
exposure rather than running `olp-keys revoke --id=<id>`).
|
||||
2. **Add an `anonymous` owner_tier instead of using `guest` + `plaintext_
|
||||
advertise`.** Rejected. Bumps ADR 0007 § 4 schema version (a
|
||||
non-additive change), adds a third identity class that the rest of the
|
||||
codebase (cache namespacing, /health gating, audit attribution) has no
|
||||
reason to know about, and conflicts with ADR 0007 § 7.1's "anonymous =
|
||||
no auth header + allow_anonymous=true" definition. A single optional
|
||||
field on the manifest is strictly less invasive.
|
||||
3. **Hard-enforce trusted-LAN bind address at startup.** Rejected for
|
||||
v0.4.0; deferred until a public-deployment-mode README section ships
|
||||
(see Re-evaluation triggers § 1). Soft constraint + startup warn is
|
||||
appropriate while OLP has zero public-internet deployment recipes.
|
||||
4. **Encrypt `plaintext_advertise` at rest with a key derived from
|
||||
`OLP_HOME` path or a separate `OLP_ADVERTISE_KEY` env var.** Rejected.
|
||||
The threat model is "anyone who can read `/health` reads the plaintext
|
||||
token over the wire," not "anyone who can read `~/.olp/keys/`." Both
|
||||
require LAN-reach; encrypting on-disk doesn't change the over-the-wire
|
||||
exposure. Adds complexity for no security gain in the relevant attack
|
||||
model.
|
||||
5. **Make `--advertise` allowed only when `--name` is exactly `anonymous`.**
|
||||
Rejected as over-restrictive. The CLI's `--anonymous` shorthand
|
||||
defaults `--name=anonymous`, but operators may legitimately want a
|
||||
named advertised key (e.g., `family-guest`, `lan-zero-config`). The
|
||||
discriminator is the field, not the name.
|
||||
|
||||
---
|
||||
|
||||
## Authority citations
|
||||
|
||||
- **ADR 0007 § 7** (Identity-class table — anonymous tier definition;
|
||||
`__anonymous__` keyId).
|
||||
- **ADR 0007 § 5** (Token format — establishes hash-only on-disk; D69 is
|
||||
the explicit opt-in exception).
|
||||
- **ADR 0007 § 4** (Manifest schema — D69 adds optional `plaintext_advertise`
|
||||
field; § 4 already specifies "unrecognized fields cause a warn but not a
|
||||
reject (forward-compat)" so the addition is non-breaking for older
|
||||
parsers).
|
||||
- **ADR 0007 § 7.2** (Configuration — D69 adds `auth.advertise_anonymous_key`
|
||||
alongside existing `allow_anonymous` / `owner_only_endpoints` /
|
||||
`fallback_detail_header_policy`).
|
||||
- **ADR 0010 § Phase 4 charter D68-D70 row** (scope authority for this ADR).
|
||||
- **OCP `server.mjs:148, 1454, 1488, 1555`** (prior-art for the
|
||||
`PROXY_ANONYMOUS_KEY` env + `/health.anonymousKey` pattern; OCP v3.13.0).
|
||||
- **OCP issue #12 § 14 Path A** (the original anonymous-key decision
|
||||
context for OCP; the "Path A" label is OCP-specific and not used in
|
||||
OLP).
|
||||
- **`bin/olp-connect`** (D68 client-side consumer of `/health.anonymousKey`).
|
||||
- **`bin/olp-keys.mjs`** (D69 keygen `--advertise` flag implementation).
|
||||
- **`lib/keys.mjs` `findAdvertisedKey()`** (D69 server-side resolver).
|
||||
- **`server.mjs handleHealth`** (D69 emission point + startup-warn site).
|
||||
|
||||
---
|
||||
|
||||
## Procedural mechanism
|
||||
|
||||
CC 开发铁律 v1.6 § 10 (independent reviewer per implementation D-day) — D68
|
||||
+ D69 + D70 ship as ONE PR per Iron Rule 11 IDR (the three deliverables
|
||||
are mutually constituting: `olp-connect` consumes `/health.anonymousKey`,
|
||||
`/health.anonymousKey` is governed by ADR 0011, ADR 0011 documents
|
||||
`olp-connect`'s trust posture). The reviewer is a fresh-context opus
|
||||
subagent.
|
||||
@@ -21,6 +21,10 @@ 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). |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
|
||||
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
|
||||
|
||||
## When to write a new ADR
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# OLP IDE & client integrations
|
||||
|
||||
This directory documents per-tool setup for the IDEs and AI clients OLP
|
||||
supports. Every page follows the same shape: one-line description, status
|
||||
icon, copy-paste-able config block, known issues, OLP-specific notes, and a
|
||||
one-line verification command.
|
||||
|
||||
## Index
|
||||
|
||||
| Tool | Status | Path | Notes |
|
||||
|---|---|---|---|
|
||||
| [Continue.dev](./continue.md) | ✅ Supported | VS Code / JetBrains extension | `config.yaml` (NOT `config.json`); supports custom headers |
|
||||
| [Cline](./cline.md) | ✅ Supported | VS Code extension | OpenAI-compatible provider; UI field occasionally vanishes (Cline #7128) |
|
||||
| [Cursor](./cursor.md) | ⚠️ Best-effort | Cursor editor | "Override OpenAI Base URL" — known fragile across releases |
|
||||
| [Aider](./aider.md) | ✅ Supported | terminal CLI | `OPENAI_API_BASE` env + `openai/` model prefix |
|
||||
| [Claude Code](./claude-code.md) | ❌ Not supported | terminal CLI | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline instead. |
|
||||
| [OpenClaw](./openclaw.md) | ✅ Supported | Telegram + Discord gateway | `/olp` slash command via the [`olp-plugin/`](../../olp-plugin/) plugin |
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Supported** — works against OLP's OpenAI-compatible `/v1/chat/completions`
|
||||
endpoint; the tool's IR fields flow through OLP's IR without lossy translation
|
||||
warnings on the documented chain.
|
||||
- ⚠️ **Best-effort** — works in current versions but the tool has known
|
||||
upstream bugs around base-URL configuration; expect occasional weirdness.
|
||||
- ❌ **Not supported** — the tool's wire protocol or transport is incompatible
|
||||
with what OLP serves; recommended alternative is documented on the page.
|
||||
|
||||
## How OLP's response headers help debugging
|
||||
|
||||
Every response carries (see [README § Response Headers](../../README.md#response-headers)):
|
||||
|
||||
- `X-OLP-Provider-Used` — which provider's plugin served the request
|
||||
- `X-OLP-Model-Used` — which model the served provider used
|
||||
- `X-OLP-Fallback-Hops` — `0` = primary chain entry served it
|
||||
- `X-OLP-Cache` — `hit | miss | bypass`
|
||||
- `X-OLP-Latency-Ms` — end-to-end latency at the proxy
|
||||
|
||||
When something looks wrong in an IDE, the first sanity check is `curl -i`
|
||||
against `/v1/chat/completions` with the same key — those headers tell you
|
||||
whether the IDE config is broken or OLP routed somewhere unexpected.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter; documents why `/v1/messages` is not supported and points to Cline as the recommended Anthropic-CLI replacement.
|
||||
- [ADR 0011](../adr/0011-anonymous-key-deployment-context.md) — trusted-LAN-only invariant for `auth.advertise_anonymous_key`.
|
||||
- [`bin/olp-connect`](../../bin/olp-connect) — automated client setup helper (D68-D70).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Aider + OLP
|
||||
|
||||
[Aider](https://aider.chat) is a terminal-native pair programmer that
|
||||
edits files in your local git repo and commits each change. It speaks
|
||||
OpenAI's `/v1/chat/completions` wire format via the `openai/` model
|
||||
prefix.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Aider v0.6x. Aider's OpenAI integration has been stable
|
||||
across many releases — this is the most reliable IDE/CLI binding to OLP.
|
||||
|
||||
## Quick setup
|
||||
|
||||
Three knobs, all environment variables or `.env`:
|
||||
|
||||
```bash
|
||||
# Required: point Aider at OLP's chat-completions endpoint
|
||||
export OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
|
||||
# Required: OLP plaintext token
|
||||
export OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Then invoke Aider with an OLP-routable model, prefixed `openai/`:
|
||||
aider --model openai/claude-sonnet-4-5
|
||||
```
|
||||
|
||||
The `openai/` prefix tells Aider to use its OpenAI-compatible adapter for
|
||||
the named model. Aider's litellm layer parses this and sends the request
|
||||
to whatever `OPENAI_API_BASE` resolves to.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=aider`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Aider's `.env` support
|
||||
|
||||
Aider auto-loads a `.env` file from the current directory or the git repo
|
||||
root. The accepted keys are:
|
||||
|
||||
```bash
|
||||
# .env at the project root
|
||||
OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Optional: Aider's own AIDER_-prefixed equivalents work too
|
||||
AIDER_OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
```
|
||||
|
||||
The `AIDER_` prefix wins over the bare prefix when both are set. Pick one;
|
||||
mixing them invites surprises during debugging.
|
||||
|
||||
**Hygiene:** add `.env` to `.gitignore` if your repo doesn't already. The
|
||||
OLP token is plaintext-recoverable from disk only because the chat surface
|
||||
explicitly opts into it (see [ADR 0011](../adr/0011-anonymous-key-deployment-context.md))
|
||||
— do not let your IDE bind unintentionally.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **No custom-headers support.** Aider does not expose a way to set extra
|
||||
HTTP headers on outgoing requests. OLP's optional `X-OLP-Chain` /
|
||||
`X-OLP-Bypass-Cache` headers are therefore not available via Aider —
|
||||
routing is determined by the model name alone.
|
||||
|
||||
- **`/v1` trailing matters.** `OPENAI_API_BASE` must end at `/v1` (without
|
||||
`/chat/completions`); Aider appends the remainder. Setting it to the bare
|
||||
host or with a trailing `/chat/completions` causes 404s.
|
||||
|
||||
- **Aider sends `max_tokens` by default.** OLP forwards `max_tokens` to
|
||||
every provider. If you see "model X does not support max_tokens" errors,
|
||||
the underlying provider rejects it — check `X-OLP-Provider-Used` and
|
||||
filter that provider out of the chain for the affected model.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Aider's request shape is faithful to OpenAI's `/v1/chat/completions`
|
||||
spec — `messages`, `model`, `max_tokens`, `stream`, `temperature`,
|
||||
`tools`. All map cleanly into OLP's IR with no lossy-translation warnings.
|
||||
|
||||
For long-context work (codebase summaries, large diffs), set
|
||||
`streaming.heartbeat_interval_ms: 15000` in `~/.olp/config.json` (see
|
||||
[README § Environment Variables](../../README.md#configjson-keys-introduced-at-phase-4))
|
||||
so the SSE stream stays alive through reverse proxies during silent
|
||||
windows.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
# In a scratch dir:
|
||||
aider --model openai/claude-haiku-4-5 --no-stream --message "say ok"
|
||||
```
|
||||
|
||||
Then check OLP's audit log:
|
||||
|
||||
```bash
|
||||
npx olp logs 5
|
||||
```
|
||||
|
||||
The most recent entry should show `provider: anthropic` (or whatever
|
||||
provider haiku routes to in your chain) and `cache_status: miss`.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Aider model config docs: https://aider.chat/docs/llms/openai-compat.html
|
||||
- [`olp-connect`](../../bin/olp-connect) writes `~/.aider/.env` if Aider is
|
||||
detected on PATH.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Claude Code + OLP
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) is
|
||||
Anthropic's official terminal-native agent. It speaks the Anthropic
|
||||
`/v1/messages` wire format and cannot be configured to use an
|
||||
OpenAI-compatible chat-completions endpoint.
|
||||
|
||||
**Status:** ❌ Not supported.
|
||||
|
||||
## Why
|
||||
|
||||
OLP serves only the OpenAI `/v1/chat/completions` wire format. Adding
|
||||
`/v1/messages` (the Anthropic shape) was explicitly considered for
|
||||
Phase 4 and rejected, per
|
||||
[ADR 0010 § Out of Phase 4 scope](../adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
The short version of the rationale:
|
||||
|
||||
- **No billing benefit.** After Anthropic's 2026-06-15 split, `claude -p` /
|
||||
Agent SDK / third-party agent traffic moves out of the Pro/Max
|
||||
subscription pool and into a separate paid Agent SDK Credit pool. OLP's
|
||||
fallback discipline ("when one provider's quota runs out, try the next")
|
||||
does not save money for this traffic — it just routes the same paid
|
||||
request to a different paid backend. The subscription leverage that
|
||||
makes OLP valuable for OpenAI-shape traffic does not exist for
|
||||
Anthropic-shape traffic.
|
||||
|
||||
- **Degrades worse on fallback.** When OLP's primary chain entry (Anthropic)
|
||||
is exhausted, the fallback hop is typically OpenAI Codex or Mistral Vibe.
|
||||
Those providers speak OpenAI tool-calling schema; OLP would have to
|
||||
translate Anthropic's `/v1/messages` tool shape into OpenAI tool shape on
|
||||
every fallback. That translation is lossy and is what ADR 0010 calls out
|
||||
as "net non-positive under P0 failure".
|
||||
|
||||
- **Same outcome reachable via the recommended alternative.** Cline, Cursor,
|
||||
Aider, and Continue.dev all speak OpenAI's wire format and have parity
|
||||
with Claude Code on the "AI edits files in my repo" use case. OLP serves
|
||||
them today.
|
||||
|
||||
## What to use instead
|
||||
|
||||
**Recommended:** [Cline](./cline.md). It's an in-IDE autonomous coder that
|
||||
operates on the same loop Claude Code does (read files, propose edits,
|
||||
run tools, iterate). The "OpenAI Compatible" provider points cleanly at
|
||||
OLP's `/v1/chat/completions` endpoint. You get OLP's full fallback chain
|
||||
(Anthropic → OpenAI Codex → Mistral) instead of being pinned to one
|
||||
provider.
|
||||
|
||||
For terminal users specifically:
|
||||
|
||||
- **[Aider](./aider.md)** if you want the Claude-Code-style git-aware
|
||||
pair programmer in the terminal.
|
||||
- **OpenClaw** if you want Telegram/Discord-driven access to the
|
||||
fallback chain (see [`openclaw.md`](./openclaw.md)).
|
||||
|
||||
## Re-open trigger
|
||||
|
||||
ADR 0010 documents the conditions under which OLP would reconsider
|
||||
`/v1/messages`:
|
||||
|
||||
> (a) ADR 0009 P0 confirms interactive-mode billing classification as
|
||||
> subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens
|
||||
> Phase 5 "Anthropic-shape hub" scope with the name of at least one
|
||||
> family member who wants CC access.
|
||||
|
||||
Until both conditions fire, OLP intentionally does not implement
|
||||
`/v1/messages`. The decision is recorded in ADR 0010 § "Out of Phase 4
|
||||
scope" and ADR 0009 (Anthropic interactive-mode path placeholder).
|
||||
|
||||
## If you absolutely must use Claude Code
|
||||
|
||||
Point Claude Code at api.anthropic.com directly. OLP cannot proxy that
|
||||
traffic. You will:
|
||||
|
||||
- Burn against the Anthropic Pro/Max OAuth subscription (pre-2026-06-15) or
|
||||
the Agent SDK Credit pool (≥ 2026-06-15).
|
||||
- Lose every fallback property OLP provides — when Anthropic's quota is
|
||||
exhausted, Claude Code stops working until the quota resets.
|
||||
- Lose OLP's response headers (`X-OLP-Provider-Used` etc.), audit log
|
||||
entries, cache hits, and `/health` visibility.
|
||||
|
||||
This is documented here only so the trade-off is explicit, not as a
|
||||
recommendation.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) § "Out of Phase 4 scope" — full defer rationale.
|
||||
- [ADR 0009](../adr/0009-interactive-mode-path-placeholder.md) — Anthropic 2026-06-15 billing split and re-open trigger.
|
||||
- [`cline.md`](./cline.md) — the recommended alternative for Claude-Code-style workflows.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Cline + OLP
|
||||
|
||||
[Cline](https://github.com/cline/cline) is an autonomous-coder VS Code
|
||||
extension. It speaks OpenAI's `/v1/chat/completions` wire format via its
|
||||
"OpenAI Compatible" provider option.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Cline v3.x (extension version visible in VS Code's
|
||||
extension panel). Cline's settings UI has shipped multiple variants of the
|
||||
base-URL field across 2025-2026; if your version doesn't show the field
|
||||
described below, see the Known Issues section.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Open the Cline panel in VS Code (sidebar icon).
|
||||
2. Click the settings gear → "API Provider".
|
||||
3. Select **OpenAI Compatible**.
|
||||
4. Fill the fields:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Base URL | `http://127.0.0.1:4567/v1` |
|
||||
| API Key | `olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` |
|
||||
| Model ID | `claude-sonnet-4-5` |
|
||||
|
||||
5. Save. Cline shows the model name in the bottom-right corner of the panel.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=cline`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Cline issue [#7128](https://github.com/cline/cline/issues/7128) —
|
||||
base-URL UI field intermittently disappears.** Several Cline releases in
|
||||
2025-2026 shipped a settings UI where the "Base URL" field is hidden when
|
||||
the "OpenAI Compatible" provider is freshly selected. Workaround: switch
|
||||
to a different provider, save, switch back to "OpenAI Compatible" — the
|
||||
field returns. Verify the field is visible in your version BEFORE
|
||||
troubleshooting OLP itself.
|
||||
|
||||
- **Cline writes settings to `.vscode/settings.json` under a
|
||||
`cline.apiConfiguration` key (workspace-scoped) and to the VS Code global
|
||||
state (machine-scoped) depending on the "save to workspace" toggle.** If
|
||||
Cline keeps "forgetting" the OLP base URL across VS Code restarts, the
|
||||
workspace state is overriding the global state. Either save to workspace
|
||||
explicitly, or clear the workspace key and use global state.
|
||||
|
||||
- **Cline sometimes lowercases the model ID before sending.** OLP's
|
||||
`models-registry.json` uses canonical case (e.g. `claude-sonnet-4-5`).
|
||||
This is fine — OLP's router lowercases the requested model for chain
|
||||
lookup. But if you see `unknown model` errors, double-check the exact
|
||||
string Cline sent via the OLP response headers (curl test below).
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Cline does not expose a custom-headers field in its OpenAI Compatible
|
||||
provider UI as of v3.x. The OLP routing chain is selected purely from the
|
||||
model ID — pick the canonical name (e.g. `claude-sonnet-4-5`) that matches
|
||||
a `routing.chains` key in your `~/.olp/config.json`.
|
||||
|
||||
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`,
|
||||
`X-OLP-Latency-Ms`) are not visible in Cline's UI but are captured by VS
|
||||
Code's Developer Tools Network panel when Cline runs the request.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
# 1. Verify OLP accepts Cline-shape requests
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5,"stream":false}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider serves
|
||||
sonnet in your chain) and `X-OLP-Cache: miss` on first request.
|
||||
|
||||
## Why not /v1/messages?
|
||||
|
||||
Cline supports Anthropic-shape requests via a separate "Anthropic" provider
|
||||
in its UI. OLP does not implement `/v1/messages`. Use Cline's **OpenAI
|
||||
Compatible** option pointed at OLP rather than Cline's **Anthropic** option
|
||||
pointed at api.anthropic.com — the OLP chain gives you fallback to OpenAI
|
||||
Codex / Mistral / etc. when the Anthropic subscription hits its quota
|
||||
ceiling. See [ADR 0010 § /v1/messages defer rationale](../adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Cline issue tracker: https://github.com/cline/cline/issues
|
||||
- [`olp-connect`](../../bin/olp-connect) automates writing the Cline workspace
|
||||
state.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Continue.dev + OLP
|
||||
|
||||
[Continue.dev](https://continue.dev) is an open-source autocomplete +
|
||||
chat extension for VS Code and JetBrains IDEs. It speaks OpenAI's
|
||||
`/v1/chat/completions` wire format, so it works against OLP with no
|
||||
shim layer.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Continue.dev v0.10.x (`config.yaml` schema). The
|
||||
older `config.json` schema (≤ v0.8) is **not** documented here — Continue
|
||||
deprecated it in late 2025 and emits a one-shot migration warning.
|
||||
|
||||
## Quick setup
|
||||
|
||||
Edit `~/.continue/config.yaml` (or open the Continue config from the IDE's
|
||||
extension panel and paste this in):
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: olp-chat
|
||||
provider: openai
|
||||
model: claude-sonnet-4-5
|
||||
apiBase: http://127.0.0.1:4567/v1
|
||||
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
roles:
|
||||
- chat
|
||||
requestOptions:
|
||||
headers:
|
||||
# Optional: pin which routing chain key applies. If omitted, OLP
|
||||
# looks up the chain via the model name above.
|
||||
X-OLP-Chain: claude-sonnet-4-5
|
||||
- name: olp-autocomplete
|
||||
provider: openai
|
||||
model: claude-haiku-4-5
|
||||
apiBase: http://127.0.0.1:4567/v1
|
||||
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
roles:
|
||||
- autocomplete
|
||||
```
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=continue-dev`. Family members on the LAN should substitute the OLP
|
||||
host's IP for `127.0.0.1` (or use `olp-connect <ip>` to do this for them
|
||||
automatically).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **`apiBase`, NOT `baseURL`.** Continue's YAML schema uses `apiBase` (no
|
||||
`URL` casing). The older `config.json` `baseURL` key was renamed during the
|
||||
v0.10 schema cut. If you copy a snippet from a 2024 blog post and it
|
||||
silently routes to api.openai.com, this is why.
|
||||
- **Trailing `/v1` matters.** OLP's chat-completions endpoint is at
|
||||
`/v1/chat/completions`; Continue appends `/chat/completions` to whatever
|
||||
`apiBase` resolves to. Set `apiBase: http://host:4567/v1` (with `/v1`),
|
||||
not the bare host.
|
||||
- **Provider stays `openai`.** Continue's `provider: anthropic` would send
|
||||
Anthropic-shape requests to `/v1/messages`, which OLP does not implement
|
||||
(see [`claude-code.md`](./claude-code.md) for the rationale).
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Continue's `requestOptions.headers` lets you pin OLP-specific routing
|
||||
behaviour without altering the model name itself. Useful headers:
|
||||
|
||||
- `X-OLP-Chain: <chain-key>` — explicitly select the routing chain.
|
||||
- `X-OLP-Bypass-Cache: true` — force a fresh spawn for the next request
|
||||
(debugging cache-poisoning suspicions).
|
||||
|
||||
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`, etc.) are
|
||||
visible via VS Code's `Developer: Toggle Developer Tools` → Network panel
|
||||
when Continue runs the request.
|
||||
|
||||
## Test it
|
||||
|
||||
After config save, open the Continue chat panel and send a one-word
|
||||
message ("ok"). Then on the terminal:
|
||||
|
||||
```bash
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider your chain
|
||||
routes haiku to) and `X-OLP-Cache: miss` on first request, `hit` on the
|
||||
second.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Continue.dev config reference: https://docs.continue.dev/customization/models
|
||||
- [`olp-connect`](../../bin/olp-connect) automates the Continue.dev branch
|
||||
of this setup.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Cursor + OLP
|
||||
|
||||
[Cursor](https://cursor.com) is an AI-first VS Code fork. It has an
|
||||
"Override OpenAI Base URL" setting that, when populated, routes its
|
||||
default-model traffic to your URL using the OpenAI wire format.
|
||||
|
||||
**Status:** ⚠️ Best-effort.
|
||||
|
||||
**Reason:** Cursor's base-URL override is known to be fragile across
|
||||
releases. Multiple 2025-2026 forum threads document the setting silently
|
||||
reverting, model-list dropdowns not populating from the override URL, and
|
||||
streaming responses falling back to the default backend on parse errors.
|
||||
The behaviour is not specific to OLP — every OpenAI-compatible proxy
|
||||
maintainer documents the same caveats — but Cursor's release cadence is
|
||||
faster than most third-party proxies can test against.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Open Cursor → Settings → "Models" → enable **OpenAI API Key**.
|
||||
2. Paste your OLP plaintext token into the **API Key** field.
|
||||
3. Click "Override OpenAI Base URL" and paste:
|
||||
|
||||
```
|
||||
http://127.0.0.1:4567/v1
|
||||
```
|
||||
|
||||
4. Click "Verify". Cursor sends a probe; on success the indicator turns
|
||||
green.
|
||||
|
||||
5. **Crucial step:** in the model list, disable every model that is NOT
|
||||
in your `~/.olp/config.json` `routing.chains`. Cursor's chat will round-
|
||||
robin across enabled models and any model OLP can't route will error.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=cursor`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Override URL silently reverts on Cursor update.** Two reported variants:
|
||||
(a) the field empties; (b) the field shows the OLP URL but Cursor still
|
||||
hits api.openai.com under the hood. Workaround: after every Cursor
|
||||
update, re-open settings, click "Verify" again, and check the OLP
|
||||
server's `/health` for incoming probe requests.
|
||||
|
||||
- **Model-list dropdown does not populate from the override URL.** Cursor
|
||||
hardcodes its model list rather than reading `GET /v1/models`. This is
|
||||
why step 5 above is required — there is no way to make Cursor "discover"
|
||||
your models. You have to disable each model individually that OLP can't
|
||||
serve.
|
||||
|
||||
- **Streaming response parsing is stricter than OpenAI's actual SSE spec.**
|
||||
Cursor occasionally falls back to the default backend if the SSE stream
|
||||
contains a slightly malformed chunk (e.g. an empty `data:` line that
|
||||
OpenAI's API does emit but Cursor's parser doesn't expect). OLP's SSE
|
||||
emitter follows the spec; this is on Cursor's side. If you see traffic
|
||||
hitting api.openai.com despite the override, this is the most likely
|
||||
cause.
|
||||
|
||||
- **Cursor's "Tab" autocomplete is NOT covered by the override.** Tab
|
||||
completion uses a Cursor-proprietary endpoint that is not affected by the
|
||||
OpenAI base URL setting. Only the chat panel is. This is documented
|
||||
Cursor behaviour and is not a bug.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Cursor sends `model: "gpt-4"` or `model: "gpt-3.5-turbo"` (legacy aliases)
|
||||
unless you explicitly select another from its dropdown. Add aliases to
|
||||
your `~/.olp/config.json` `routing.chains` so these route somewhere sane:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"chains": {
|
||||
"gpt-4": [ { "provider": "openai", "model": "gpt-5" } ],
|
||||
"gpt-3.5-turbo": [ { "provider": "openai", "model": "gpt-5-mini" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Substitute the OpenAI Codex model names listed by `olp models`.)
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Do not engineer workarounds for Cursor-side bugs.** Cursor's release
|
||||
cadence will fix or re-break the override URL handling at unpredictable
|
||||
intervals. If your daily-driver flow is unreliable, switch to Cline (see
|
||||
[`cline.md`](./cline.md)) — it has a stable OpenAI-compatible provider
|
||||
that does not break across releases.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-4","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
After hitting "Send" in Cursor's chat, check the OLP server's recent
|
||||
requests via:
|
||||
|
||||
```bash
|
||||
npx olp logs 10
|
||||
```
|
||||
|
||||
If you don't see Cursor's request in the audit log, traffic isn't reaching
|
||||
OLP — re-check the override URL.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Cursor forum threads on base-URL fragility: https://forum.cursor.com/ (search "OpenAI base URL")
|
||||
- [`olp-connect`](../../bin/olp-connect) writes Cursor's `cursorrc` if
|
||||
detected, but cannot guarantee the override survives a Cursor update.
|
||||
@@ -0,0 +1,146 @@
|
||||
# OpenClaw + OLP
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is a multi-bot gateway
|
||||
that exposes slash commands on Telegram, Discord, and other chat
|
||||
surfaces. OLP ships [`olp-plugin/`](../../olp-plugin/) as a native
|
||||
OpenClaw plugin that registers a `/olp` slash command with read-only
|
||||
parity to the local `olp` CLI.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
## What you get
|
||||
|
||||
After install, from Telegram or Discord:
|
||||
|
||||
| Slash command | Maps to | Tier |
|
||||
|---|---|---|
|
||||
| `/olp status` | GET `/v0/management/status` | owner |
|
||||
| `/olp health` | GET `/health` | public |
|
||||
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
|
||||
| `/olp models` | GET `/v1/models` | public |
|
||||
| `/olp cache` | GET `/cache/stats` | owner |
|
||||
| `/olp providers` | local registry view | public |
|
||||
| `/olp chain show [model]` | local chain view | public |
|
||||
| `/olp doctor` | informational (HTTP endpoint not yet shipped) | — |
|
||||
| `/olp help` | usage text | — |
|
||||
|
||||
**Mutating subcommands are deliberately not exposed via chat.** `keygen`,
|
||||
`revoke`, `restart`, `logs` are SSH-only. See
|
||||
[`olp-plugin/README.md`](../../olp-plugin/README.md#what-you-can-not-do-from-chat-by-design)
|
||||
for the rationale.
|
||||
|
||||
## Quick setup
|
||||
|
||||
### 1. Install the plugin
|
||||
|
||||
Two install paths — either works.
|
||||
|
||||
**Option A — OpenClaw CLI:**
|
||||
|
||||
```bash
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
```
|
||||
|
||||
**Option B — symlink:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
### 2. Mint a bot owner key
|
||||
|
||||
Run on the OLP host (NOT in chat):
|
||||
|
||||
```bash
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
```
|
||||
|
||||
Capture the printed plaintext token — it is shown exactly once.
|
||||
|
||||
### 3. Configure
|
||||
|
||||
Edit `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Restart the gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
The plugin is now active. Try `/olp help` in your bot's chat.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **`openclaw gateway restart` is required after install.** OpenClaw caches
|
||||
plugin discovery at gateway start. `openclaw plugins reload` does not
|
||||
guarantee a fresh import of the plugin module.
|
||||
|
||||
- **Owner key revocation kicks the plugin out immediately.** If you revoke
|
||||
the bot's owner key (`npx olp-keys revoke --id=<id>`), the next `/olp
|
||||
status` will return `401 unauthorized`. Mint a replacement key with a
|
||||
new name and edit `~/.openclaw/openclaw.json`; do NOT reuse the revoked
|
||||
key's UUID.
|
||||
|
||||
- **Long responses are truncated.** Telegram caps messages at ~4096
|
||||
characters. The plugin truncates with a `... [truncated, use SSH for
|
||||
full]` suffix when the rendered output would exceed ~3900 chars. Use
|
||||
SSH + the local `olp` CLI for full output.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
The plugin honours these env vars on the OpenClaw gateway process:
|
||||
|
||||
- `OLP_PROXY_URL` — full URL, overrides plugin config `proxyUrl`.
|
||||
- `OLP_PORT` — port only, localhost assumed; overrides `proxyUrl` when
|
||||
`OLP_PROXY_URL` is unset.
|
||||
|
||||
If you run the OpenClaw gateway under launchd or systemd with custom env
|
||||
vars, set `OLP_PROXY_URL` there rather than editing the plugin config —
|
||||
that way the same plugin install can serve multiple OLP hosts.
|
||||
|
||||
## Per-bot vs maintainer key
|
||||
|
||||
**Always create a dedicated bot key**, never the maintainer's personal
|
||||
owner key. The bot key:
|
||||
|
||||
- Has its own `id` so you can revoke it without affecting other clients.
|
||||
- Has its own audit-log entries so you can attribute `/v0/management/*`
|
||||
traffic to the bot.
|
||||
- Can be rotated routinely (every 90 days etc.) without coordinating with
|
||||
the maintainer's daily-driver IDE configs.
|
||||
|
||||
## Test it
|
||||
|
||||
After restart, in Telegram or Discord:
|
||||
|
||||
```
|
||||
/olp health
|
||||
/olp status
|
||||
/olp models
|
||||
```
|
||||
|
||||
Each should return a code-block-wrapped response within a few seconds.
|
||||
|
||||
If you see `401 unauthorized`: the configured key is missing / wrong /
|
||||
revoked. If you see `403 forbidden`: the key is not owner-tier. If you
|
||||
see `OLP error: fetch failed` or similar: the `proxyUrl` is unreachable
|
||||
from the gateway host (test with `curl http://<proxyUrl>/health` from
|
||||
that host).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`olp-plugin/README.md`](../../olp-plugin/README.md) — full plugin docs.
|
||||
- [ADR 0010 § Phase 4 D71-D73](../adr/0010-phase-4-charter-operator-and-client-ux.md) — the plugin's charter.
|
||||
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor (includes mutating subcommands that OLP deliberately drops).
|
||||
+16
-13
@@ -8,21 +8,23 @@
|
||||
3. **Where** does the work live in the tree today (file + anchor).
|
||||
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
|
||||
|
||||
**Reading order for a v1.x sprint kickoff.** Items #1–#3 are the most architecturally consequential and should be designed in dependency order: #2 (multi-key auth) blocks header gating in #1 and observability ownership in #4. #1 (streaming SF) blocks #5 (soft trigger reactivation) only if soft triggers are wired on streaming requests.
|
||||
**Reading order for a v1.x sprint kickoff.** As of 2026-05-25, #1 (streaming SF, D57+D58) and #2 (multi-key auth, Phase 2) are CLOSED, and #4 and #7 closed in D56. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
|
||||
|
||||
---
|
||||
|
||||
## #1 — Streaming-path singleflight + TOCTOU close
|
||||
## #1 — Streaming-path singleflight + TOCTOU close — ✅ **SHIPPED (D57 + D58, 2026-05-25)**
|
||||
|
||||
- **What.** `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory)` API replacing the current `peek + spawn` pattern in `server.mjs`. Per-(keyId, cacheKey) inflight Map with tee fan-out, bounded per-client backpressure queues, late-joiner replay buffer, AbortController propagation on all-disconnect.
|
||||
- **Why deferred.** Personal/family-scale single-tenant load — N concurrent identical streaming requests is an edge case that has not been reported. Each concurrent caller receives the correct response; the waste is N CLI processes instead of one.
|
||||
- **Design ADR (ratified).** [`docs/adr/0005-cache-cross-provider.md` Amendment 8](./adr/0005-cache-cross-provider.md) — full design including the inflight Map shape, tee policy, late-joiner replay, backpressure cap, D38 semaphore coordination, abort policy, cache TTL race handling, observability event set, and X-OLP-Streaming-Inflight header. Implementation acceptance criteria are in Amendment 8 §13.
|
||||
- **Tracking issue.** GitHub issue [#16](https://github.com/dtzp555-max/olp/issues/16) — STAYS OPEN as v1.x tracker. Sibling: the closed-but-not-implemented Amendment 6 deferral (D34 F1).
|
||||
- **Code anchors today.**
|
||||
- `server.mjs` lines ~782 (`preCheckHit = await cacheStore.peek(...)`) and ~811–817 (streaming branch entry) — these are the lines the new API replaces.
|
||||
- `lib/cache/store.mjs` `getOrCompute` — sibling API; the new one mirrors its shape on the streaming path.
|
||||
- **Trigger to start.** Any of: (a) report of N>1 concurrent identical streaming requests in the wild, (b) v1.x sprint planning kickoff with the maintainer explicitly opening this scope, (c) downstream feature requiring tee-streaming primitive (e.g., browser-side observer attaching to an existing stream).
|
||||
- **Estimated effort.** Design ADR ratified (Amendment 8) = 30 min done. Implementation = 200–400 lines + 15-20 tests + fresh-context reviewer pass. ~3-4 hours of subagent runtime with full Iron Rule 10 discipline.
|
||||
- **Status.** Closed. Trigger (b) fired 2026-05-25 — maintainer "go" after v0.3.1. Shipped across three D-days:
|
||||
- **D57** (PR #36) — cache layer: `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts) → { stream, isFirst, role }` with `_streamingInflight` Map, tee fan-out, late-joiner replay buffer, per-client backpressure (`PER_CLIENT_QUEUE_CAP=1MB`), replay cap (`ACCUMULATED_REPLAY_CAP=10MB`), AbortController propagation, synchronous Map check+insert (closes TOCTOU). Suite 27 = 12 unit tests.
|
||||
- **D58** (PR #37) — server.mjs wiring: streaming branch swap; `tryAcquireSpawn`/`releaseSpawn` moved inside `sourceFactory` closure (D38 §7 coordination); `CONCURRENCY_LIMIT` fallthrough preserved; `X-OLP-Streaming-Inflight: source | attached` header; `cache_status: 'streaming_attached'` audit value + audit-query gauge reconciliation; `res.on('close') → stream.return()` for client disconnect; D16 truncated-not-cached invariant preserved via `cacheStore.delete` on stop-less exhaustion. Suite 28 = 8 HTTP integration tests.
|
||||
- **D59** (this commit) — docs polish: README known-limitations entry inverted; this roadmap entry closed; issue #16 closed.
|
||||
- **Design authority.** [`docs/adr/0005-cache-cross-provider.md` Amendment 8](./adr/0005-cache-cross-provider.md) — implemented per spec §§1–14 across D57+D58.
|
||||
- **Tracking issue.** GitHub issue [#16](https://github.com/dtzp555-max/olp/issues/16) — CLOSED at D59 with refs to D57+D58 PRs.
|
||||
- **Final test count delta.** 603 (v0.3.1) → 623 (v0.3.2/v0.4.0). +20 tests across the SF arc.
|
||||
- **Deferred sub-items** (left here as future-work pointers, NOT blocking #1 closure):
|
||||
- `X-OLP-Streaming-Inflight: solo` value not emitted on the wire (Amendment 8 §11). It's observable only post-stream via the `streaming_inflight_source_done` log event's `attached_count: 0`. Future ADR amendment may expose via HTTP trailer.
|
||||
- `streaming_inflight_join` log event from `_attachClient` cache-layer path (carries no provider/model context). D58 emits the event from the server-layer wrapper instead; cache-layer emission would need a provider/model plumb (TODO marker at `lib/cache/store.mjs:~620`).
|
||||
- `isFirst` field returned by `getOrComputeStreaming` is currently unused by server.mjs (`role` supersedes). Could be removed in a future cache-layer API cleanup.
|
||||
|
||||
## #2 — Multi-key auth (`lib/keys.mjs`) — **PHASE 2 ACTIVE (no longer deferred)**
|
||||
|
||||
@@ -81,9 +83,10 @@
|
||||
|
||||
- **What.** Currently the streaming branch does NOT participate in D16 salvage (the salvage-on-SPAWN_FAILED + chunks pattern that the buffered path uses). Streaming SPAWN_FAILED mid-stream → the truncation marker (D35 #10) fires, but no salvage logic captures partial chunks for downstream cache reuse.
|
||||
- **Why deferred.** Less impactful than #1 — at most one client benefits per spawn event, and the buffered path already provides salvage for the bulk of requests. Streaming is the minority path.
|
||||
- **Design ADR.** Not yet ratified. Coordinated with #1 because the tee architecture changes the salvage semantics (multiple clients may want different finish_reason interpretations on source-mid-stream-failure).
|
||||
- **Status update post-#1 close (2026-05-25).** #1 was originally bundled with #6 in the design ADR (Amendment 8). The tee architecture as implemented does NOT carry salvage semantics — D57's tee writes `accumulatedChunks` to cache only on normal source completion (stop chunk seen); on SPAWN_FAILED mid-stream the cache layer rejects all clients with the error and does NOT persist partial chunks. D58 preserves D16's truncated-not-cached invariant via server-layer `cacheStore.delete` on stop-less exhaustion. #6 therefore remains independently deferrable.
|
||||
- **Design ADR.** Not yet ratified. The unbundling from #1 means #6 now needs its own ADR amendment when triggered.
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Trigger to start.** Bundled with #1 implementation work (the inflight tee architecture changes the salvage semantics, so designing them together is cheaper than serializing).
|
||||
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
|
||||
|
||||
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up)
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 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, cache_streaming_attached, 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, cache_streaming_attached: 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++;
|
||||
// D58 — ADR 0005 Amendment 8 §11 + lib/audit.mjs cache_status enum: streaming
|
||||
// singleflight joiners (attached) share the source spawn but did not hit a
|
||||
// cache. Tracked separately so `count` and `cache_hit + cache_miss +
|
||||
// cache_bypass + cache_streaming_attached` reconcile.
|
||||
else if (ev.cache_status === 'streaming_attached') p.cache_streaming_attached++;
|
||||
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, streaming_attached, hit_rate, by_provider }
|
||||
*
|
||||
* `streaming_attached` (D58, ADR 0005 Amendment 8 §11): D58 streaming
|
||||
* singleflight joiners did not hit a literal cache, so they are excluded
|
||||
* from both numerator AND denominator of `hit_rate`. Tracked separately
|
||||
* so the count reconciles with `total = hit + miss + bypass + streaming_attached`.
|
||||
*
|
||||
* @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, streaming_attached = 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, streaming_attached: 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++; }
|
||||
// D58 — ADR 0005 Amendment 8 §11: streaming singleflight joiners.
|
||||
// Excluded from hit_rate numerator + denominator (they did not hit a
|
||||
// literal cache); tracked so `total` reconciles.
|
||||
else if (ev.cache_status === 'streaming_attached') { streaming_attached++; pe.streaming_attached++; }
|
||||
}
|
||||
|
||||
// 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, streaming_attached, hit_rate, by_provider,
|
||||
};
|
||||
}
|
||||
+188
-2
@@ -23,15 +23,23 @@
|
||||
* 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 { homedir } from 'node:os';
|
||||
|
||||
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||
const OLP_HOME_ENV = 'OLP_HOME';
|
||||
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 _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 →
|
||||
@@ -44,6 +52,120 @@ function _resolveOlpHome(opts) {
|
||||
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.
|
||||
*
|
||||
@@ -52,6 +174,20 @@ function _resolveOlpHome(opts) {
|
||||
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
|
||||
* Caller is responsible for populating fields; missing fields are
|
||||
* serialized as undefined → omitted by JSON.stringify.
|
||||
*
|
||||
* cache_status enum (free-form string; not schema-validated at append):
|
||||
* 'hit' — served from cache (buffered-replay or streaming
|
||||
* cache_hit role from ADR 0005 Amendment 8 §1).
|
||||
* 'miss' — buffered or streaming source path; provider
|
||||
* spawn fired for this request.
|
||||
* 'bypass' — D2 cache_control bypass (no cache read/write).
|
||||
* 'streaming_attached' — D58 / ADR 0005 Amendment 8 §11: client joined
|
||||
* an in-flight streaming source spawn from
|
||||
* another caller; this request did NOT spawn a
|
||||
* provider but also did NOT hit the cache (the
|
||||
* cache was empty at the inflight Map check).
|
||||
* null — pre-chain error paths (the cache layer was
|
||||
* never consulted; e.g. 401, 415, 400 IR).
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
|
||||
@@ -60,13 +196,28 @@ function _resolveOlpHome(opts) {
|
||||
export function appendAuditEvent(event, opts = {}) {
|
||||
const olpHome = _resolveOlpHome(opts);
|
||||
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 logEvent = opts.logEvent ?? ((level, ev, data) => {
|
||||
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||
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++) {
|
||||
try {
|
||||
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||
@@ -108,3 +259,38 @@ export function getAuditDropCount() {
|
||||
export function __resetAuditDropCount() {
|
||||
_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;
|
||||
}
|
||||
|
||||
Vendored
+582
-9
@@ -51,6 +51,75 @@
|
||||
* @property {number} inflightCount
|
||||
*/
|
||||
|
||||
// ── D57 — ADR 0005 Amendment 8 streaming-singleflight shapes ──────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} StreamingInflightEntry
|
||||
* @property {string} compositeKey - `${keyId}\0${cacheKey}`
|
||||
* @property {AsyncIterator<*>|null} source - underlying source iterator (null while factory pending)
|
||||
* @property {AbortController} sourceAbortController - propagates "all clients gone" to the source
|
||||
* @property {Array<*>} accumulatedChunks - late-joiner replay buffer (bounded by §10)
|
||||
* @property {number} accumulatedByteSize - running byte size of accumulatedChunks
|
||||
* @property {boolean} accumulatedReplayCapExceeded - true once §10 cap hit; cache write will skip
|
||||
* @property {Set<AttachedClient>} attachedClients - all live clients tee'ing this source
|
||||
* @property {boolean} factoryPending - true while sourceFactory() is awaited
|
||||
* @property {Array<{ resolve: function, reject: function }>} pendingJoiners - late joiners arriving during factoryPending
|
||||
* @property {boolean} sourceDone - source iterator exhausted normally
|
||||
* @property {Error|null} sourceError - non-null if source threw
|
||||
* @property {boolean} sourceAborted - true if AbortController fired
|
||||
* @property {number} ttlMs - TTL to use when writing the completed accumulated chunks to cache
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttachedClient
|
||||
* @property {string} id - request id / correlator
|
||||
* @property {Array<*>} queue - per-client tee buffer
|
||||
* @property {number} queueByteSize - running byte size sum
|
||||
* @property {boolean} yieldedAccumulated - true after late-joiner replay drained
|
||||
* @property {boolean} done - terminal sentinel hit
|
||||
* @property {boolean} backpressured - true once STREAM_BACKPRESSURE terminator scheduled
|
||||
* @property {Error|null} error - non-null if source threw or replay-drain over-cap
|
||||
* @property {((chunk: { value: *, done: boolean }) => void)|null} resolveNext - pending pull promise resolver
|
||||
* @property {((err: Error) => void)|null} rejectNext - pending pull promise rejecter
|
||||
*/
|
||||
|
||||
// ADR 0005 Amendment 8 §14 — implementation defaults. Per-call overrides flow
|
||||
// via the `opts` argument to getOrComputeStreaming (used by tests to exercise
|
||||
// caps cheaply without producing megabytes of fixture data).
|
||||
export const PER_CLIENT_QUEUE_CAP_DEFAULT = 1 * 1024 * 1024;
|
||||
export const ACCUMULATED_REPLAY_CAP_DEFAULT = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Returns an approximate byte size for a chunk. The tee + cap math uses
|
||||
* JSON.stringify length as the serialization yardstick (matches the cache
|
||||
* store's existing size accounting via Buffer.byteLength(JSON.stringify(...))).
|
||||
* Non-stringifiable values (circular, etc.) fall back to 0 — the tee continues
|
||||
* but the size accounting under-estimates that chunk. In practice IR chunks
|
||||
* are always JSON-safe.
|
||||
*
|
||||
* @param {*} chunk
|
||||
* @returns {number}
|
||||
*/
|
||||
function _chunkByteSize(chunk) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(chunk) ?? '', 'utf8');
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesises a STREAM_BACKPRESSURE terminator stream (ADR 0005 Amendment 8 §8):
|
||||
* yields `{ type: 'stop', finish_reason: 'length' }` then a `[DONE]` sentinel
|
||||
* then ends. Used both for late-joiner-too-late and per-client overflow paths.
|
||||
*
|
||||
* @returns {AsyncGenerator<*>}
|
||||
*/
|
||||
async function* _backpressureTerminator() {
|
||||
yield { type: 'stop', finish_reason: 'length' };
|
||||
yield '[DONE]';
|
||||
}
|
||||
|
||||
// ── CacheStore ────────────────────────────────────────────────────────────
|
||||
|
||||
export class CacheStore {
|
||||
@@ -82,6 +151,14 @@ export class CacheStore {
|
||||
/** @type {Map<string, Promise<*>>} */
|
||||
this._inflight = new Map();
|
||||
|
||||
// D57 — ADR 0005 Amendment 8: streaming singleflight per-(keyId,cacheKey)
|
||||
// inflight Map. Composite key uses `\0` separator per §2 (avoids colliding
|
||||
// with keyId or cacheKey content). The check + insert against this Map is
|
||||
// synchronous in getOrComputeStreaming (no `await` between read & write),
|
||||
// mirroring D38 `tryAcquireSpawn` atomicity.
|
||||
/** @type {Map<string, StreamingInflightEntry>} */
|
||||
this._streamingInflight = new Map();
|
||||
|
||||
// Stats per keyId
|
||||
/** @type {Map<string, { hits: number, misses: number }>} */
|
||||
this._stats = new Map();
|
||||
@@ -266,13 +343,10 @@ export class CacheStore {
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {Promise<*>}
|
||||
*
|
||||
* TODO(v1.x — ADR 0005 Amendment 8 / issue #16): add a sibling
|
||||
* `getOrComputeStreaming(keyId, cacheKey, sourceFactory)` for the streaming
|
||||
* path. This API handles buffered responses only; the streaming branch in
|
||||
* server.mjs currently uses a peek+spawn pattern with a TOCTOU window.
|
||||
* The streaming sibling will mirror this method's shape but with a tee
|
||||
* fan-out and per-client backpressure queues. See docs/v1x-roadmap.md #1
|
||||
* for the design contract and acceptance criteria.
|
||||
* D57 — ADR 0005 Amendment 8 / issue #16 resolved at the cache layer:
|
||||
* the sibling `getOrComputeStreaming` method below provides tee-fan-out +
|
||||
* per-client backpressure for the streaming path. Server-side wiring lands
|
||||
* separately in D58.
|
||||
*/
|
||||
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
|
||||
// 1. Cache hit — return immediately, no singleflight overhead
|
||||
@@ -311,6 +385,493 @@ export class CacheStore {
|
||||
return computePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* D57 — ADR 0005 Amendment 8 (issue #16): streaming singleflight + tee-fan-out.
|
||||
*
|
||||
* Streaming-path sibling of `getOrCompute`. Coordinates concurrent identical
|
||||
* streaming requests so only one source spawn occurs, all attached clients
|
||||
* receive identical chunk sequences in order, late joiners are replayed from
|
||||
* an accumulated buffer, slow clients are disconnected with a synthetic
|
||||
* STREAM_BACKPRESSURE terminator instead of stalling the source, and the
|
||||
* source is aborted when all clients disconnect.
|
||||
*
|
||||
* The Map check + insert against `_streamingInflight` is synchronous — no
|
||||
* `await` between read and write — matching the D38 `tryAcquireSpawn`
|
||||
* atomicity invariant and collapsing the original TOCTOU window in
|
||||
* `server.mjs:782` (peek + spawn). See §1 + §6.
|
||||
*
|
||||
* Three outcomes (Amendment 8 §1):
|
||||
* - **cache_hit**: cached entry exists and is alive → returns
|
||||
* `{ stream: <async iterator over cached chunks>, isFirst: false,
|
||||
* role: 'cache_hit' }`. No spawn. `hits` incremented.
|
||||
* - **attached**: inflight entry exists in `_streamingInflight` → attaches
|
||||
* a new AttachedClient. Late-joiner replay (§5) drains accumulated
|
||||
* chunks synchronously; if drain would exceed `perClientQueueCap` the
|
||||
* client receives a STREAM_BACKPRESSURE synthesised stream instead.
|
||||
* `isFirst: false`, `role: 'attached'`. `hits` incremented (sharing a
|
||||
* spawn is functionally a "cache-like" benefit; documented choice).
|
||||
* - **source**: no cache hit, no inflight entry → the cache layer takes
|
||||
* the inflight lock synchronously (placeholder entry inserted), then
|
||||
* `await sourceFactory()`. If the factory throws (e.g.
|
||||
* CONCURRENCY_LIMIT) the placeholder is removed and the error
|
||||
* propagates. On success the source is wired up, the tee task starts,
|
||||
* `isFirst: true`, `role: 'source'`. `misses` incremented.
|
||||
*
|
||||
* **`role` enum** (returned at attach-time):
|
||||
* - `source` — first caller; entry created. Lifetime-end may upgrade to
|
||||
* `solo` (no joiners ever attached) at the server (D58); the cache
|
||||
* layer reports `source` at attach-time and does not flip mid-stream.
|
||||
* - `attached` — joined an existing inflight entry.
|
||||
* - `cache_hit` — served from cache; no entry created.
|
||||
*
|
||||
* **Amendment 8 §11 header values**: the X-OLP-Streaming-Inflight header
|
||||
* (D58) uses `source | attached | solo`. The cache layer's `cache_hit` is
|
||||
* the "served from cache without inflight" case; D58 chooses whether to
|
||||
* emit `solo` or omit the header for that path. The cache layer reports
|
||||
* `cache_hit` as a distinct role so the server can disambiguate.
|
||||
*
|
||||
* **§14 defaults**: `PER_CLIENT_QUEUE_CAP = 1 MB`, `ACCUMULATED_REPLAY_CAP
|
||||
* = 10 MB`. Both overridable via `opts` for cheap test exercise of the cap
|
||||
* paths.
|
||||
*
|
||||
* @param {string} keyId
|
||||
* @param {string} cacheKey
|
||||
* @param {() => Promise<AsyncIterator<*>>|AsyncIterator<*>} sourceFactory
|
||||
* Invoked exactly once per inflight lifetime (only on first caller).
|
||||
* Returns the source async iterator. May throw (e.g. CONCURRENCY_LIMIT
|
||||
* from `tryAcquireSpawn`); on throw, the inflight entry is removed and
|
||||
* the error propagates to the first caller. Late joiners that attached
|
||||
* while the factory was pending are rejected with the same error.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.clientId] - correlator id (defaults to incrementing counter)
|
||||
* @param {number} [opts.ttlMs] - TTL for completed accumulated-chunks cache write
|
||||
* @param {number} [opts.perClientQueueCap] - override §14 default (1 MB)
|
||||
* @param {number} [opts.accumulatedReplayCap] - override §14 default (10 MB)
|
||||
* @returns {Promise<{ stream: AsyncGenerator<*>, isFirst: boolean, role: 'source'|'attached'|'cache_hit' }>}
|
||||
*/
|
||||
async getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts = {}) {
|
||||
const compositeKey = `${keyId}\0${cacheKey}`;
|
||||
const perClientQueueCap = opts.perClientQueueCap ?? PER_CLIENT_QUEUE_CAP_DEFAULT;
|
||||
const accumulatedReplayCap = opts.accumulatedReplayCap ?? ACCUMULATED_REPLAY_CAP_DEFAULT;
|
||||
const ttlMs = opts.ttlMs;
|
||||
const clientId = opts.clientId ?? `c-${this._nowFn()}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
||||
|
||||
// ── (A) Inflight Map check FIRST (Amendment 8 §6 — TTL race) ───────────
|
||||
// Late joiners that arrive after a cache entry has expired but during an
|
||||
// active source spawn must still attach via the inflight Map rather than
|
||||
// re-spawn. The synchronous Map.get + (if hit) Map preservation here
|
||||
// satisfies the no-`await`-between-check-and-decision invariant.
|
||||
const inflightEntry = this._streamingInflight.get(compositeKey);
|
||||
if (inflightEntry) {
|
||||
// Hits-as-share decision (documented at method header): sharing a spawn
|
||||
// is a cache-like benefit; increment hits for consistency with
|
||||
// cache_hit accounting and to expose the singleflight win in stats().
|
||||
this._getStats(keyId).hits++;
|
||||
const stream = this._attachClient(inflightEntry, {
|
||||
clientId,
|
||||
perClientQueueCap,
|
||||
});
|
||||
return { stream, isFirst: false, role: 'attached' };
|
||||
}
|
||||
|
||||
// ── (B) Cache hit check (no inflight) ──────────────────────────────────
|
||||
// Replays cached chunks via a synthetic async iterator. No source spawn.
|
||||
const ns = this._getNamespace(keyId);
|
||||
const existing = ns.get(cacheKey);
|
||||
if (existing && this._isAlive(existing)) {
|
||||
this._getStats(keyId).hits++;
|
||||
const cachedChunks = Array.isArray(existing.value) ? existing.value : [existing.value];
|
||||
const stream = (async function* cacheReplay() {
|
||||
for (const chunk of cachedChunks) {
|
||||
yield chunk;
|
||||
}
|
||||
})();
|
||||
return { stream, isFirst: false, role: 'cache_hit' };
|
||||
}
|
||||
|
||||
// ── (C) Miss + no inflight: take the lock synchronously, then await ───
|
||||
// The placeholder entry is inserted BEFORE invoking sourceFactory so that
|
||||
// late joiners arriving while the factory is awaited see the inflight
|
||||
// entry and attach (they're parked in `pendingJoiners` until the factory
|
||||
// resolves or rejects). If the factory throws, the placeholder is
|
||||
// removed and the error propagates to the first caller AND all parked
|
||||
// joiners. This preserves the §1 invariant: the Map insert is atomic
|
||||
// from later joiners' perspective.
|
||||
const entry = /** @type {StreamingInflightEntry} */ ({
|
||||
compositeKey,
|
||||
source: null,
|
||||
sourceAbortController: new AbortController(),
|
||||
accumulatedChunks: [],
|
||||
accumulatedByteSize: 0,
|
||||
accumulatedReplayCapExceeded: false,
|
||||
attachedClients: new Set(),
|
||||
factoryPending: true,
|
||||
pendingJoiners: [],
|
||||
sourceDone: false,
|
||||
sourceError: null,
|
||||
sourceAborted: false,
|
||||
ttlMs,
|
||||
});
|
||||
this._streamingInflight.set(compositeKey, entry);
|
||||
this._getStats(keyId).misses++;
|
||||
|
||||
// Attach the first caller synchronously so any subsequent joiners during
|
||||
// the factory await see the same set/topology as the first caller.
|
||||
const firstStream = this._attachClient(entry, {
|
||||
clientId,
|
||||
perClientQueueCap,
|
||||
});
|
||||
|
||||
let sourceIter;
|
||||
try {
|
||||
const factoryResult = sourceFactory();
|
||||
sourceIter = factoryResult && typeof factoryResult.then === 'function'
|
||||
? await factoryResult
|
||||
: factoryResult;
|
||||
} catch (err) {
|
||||
// Factory rejected — remove placeholder and reject first caller + any
|
||||
// late joiners that arrived during the await.
|
||||
this._streamingInflight.delete(compositeKey);
|
||||
for (const client of entry.attachedClients) {
|
||||
if (client.rejectNext) {
|
||||
client.rejectNext(err);
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
}
|
||||
client.error = err;
|
||||
client.done = true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
entry.source = sourceIter;
|
||||
entry.factoryPending = false;
|
||||
|
||||
// Kick off the tee task. It runs detached; lifetime is bounded by the
|
||||
// source iterator's completion / error / abort.
|
||||
this._teeStreamingSource(keyId, cacheKey, entry, {
|
||||
accumulatedReplayCap,
|
||||
});
|
||||
|
||||
return { stream: firstStream, isFirst: true, role: 'source' };
|
||||
}
|
||||
|
||||
/**
|
||||
* D57 — ADR 0005 Amendment 8 §3 + §5: attach a new client to an inflight
|
||||
* entry. Synchronously drains the accumulated replay buffer into the
|
||||
* client's queue (§5). If the drain would exceed `perClientQueueCap`, the
|
||||
* client receives a STREAM_BACKPRESSURE synthesised stream INSTEAD of the
|
||||
* normal tee — the source continues for the other clients.
|
||||
*
|
||||
* Returns the async iterator the caller will consume.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_attachClient(entry, { clientId, perClientQueueCap }) {
|
||||
// Late-joiner replay drain cap check (§5 + §10): a late joiner cannot
|
||||
// catch up if either
|
||||
// (a) the accumulated buffer alone would overflow the per-client cap
|
||||
// (burst > PER_CLIENT_QUEUE_CAP), or
|
||||
// (b) the replay buffer is already truncated (§10 cap was hit and
|
||||
// further source chunks were not appended to accumulatedChunks),
|
||||
// so even a successful drain would give the joiner a partial view
|
||||
// that disagrees with later live chunks.
|
||||
// Either condition → STREAM_BACKPRESSURE synthetic terminator. The
|
||||
// source / other clients are unaffected.
|
||||
if (
|
||||
entry.accumulatedByteSize > perClientQueueCap
|
||||
|| entry.accumulatedReplayCapExceeded
|
||||
) {
|
||||
this._warnFn('stream_backpressure_disconnect', {
|
||||
client_id: clientId,
|
||||
queue_byte_size: entry.accumulatedByteSize,
|
||||
per_client_cap: perClientQueueCap,
|
||||
composite_key: entry.compositeKey,
|
||||
reason: entry.accumulatedReplayCapExceeded
|
||||
? 'replay_cap_truncated'
|
||||
: 'replay_drain_over_cap',
|
||||
});
|
||||
return _backpressureTerminator();
|
||||
}
|
||||
|
||||
/** @type {AttachedClient} */
|
||||
const client = {
|
||||
id: clientId,
|
||||
queue: [],
|
||||
queueByteSize: 0,
|
||||
yieldedAccumulated: false,
|
||||
done: false,
|
||||
backpressured: false,
|
||||
error: null,
|
||||
resolveNext: null,
|
||||
rejectNext: null,
|
||||
// Per-client cap is captured here so the tee task can apply it
|
||||
// without re-plumbing opts; documented as an internal field.
|
||||
__perClientQueueCap__: perClientQueueCap,
|
||||
};
|
||||
|
||||
// Synchronous replay drain — push every accumulated chunk into the
|
||||
// client's queue at attach-time. From this point on the tee task pushes
|
||||
// live chunks.
|
||||
for (const chunk of entry.accumulatedChunks) {
|
||||
client.queue.push(chunk);
|
||||
client.queueByteSize += _chunkByteSize(chunk);
|
||||
}
|
||||
client.yieldedAccumulated = true;
|
||||
entry.attachedClients.add(client);
|
||||
|
||||
// TODO(D58 — ADR 0005 Amendment 8 §11): emit `streaming_inflight_join`
|
||||
// event from the server-layer wrapper, which has provider/model context.
|
||||
// Cache layer alone does not have provider/model identity (sourceFactory
|
||||
// is a closure), so the join event lives at the consumer of `role:
|
||||
// 'attached'` in server.mjs. D57 reviewer P2-3 follow-up.
|
||||
|
||||
// If source already completed before this attach (last-second join)
|
||||
// mark the client as terminal-after-drain so the iterator returns
|
||||
// cleanly once the replay queue is drained.
|
||||
if (entry.sourceDone) {
|
||||
client.done = true;
|
||||
} else if (entry.sourceError) {
|
||||
client.error = entry.sourceError;
|
||||
client.done = true;
|
||||
}
|
||||
|
||||
// Per-client AbortController for client-side cancellation (HTTP close).
|
||||
// The async iterator's return() removes the client from attachedClients;
|
||||
// if the entry's attachedClients size hits zero, the tee task aborts the
|
||||
// source. The teardown logic lives in the iterator below.
|
||||
const store = this;
|
||||
const iterator = (async function* clientStream() {
|
||||
try {
|
||||
while (true) {
|
||||
// Drain queue chunks first.
|
||||
if (client.queue.length > 0) {
|
||||
const next = client.queue.shift();
|
||||
client.queueByteSize -= _chunkByteSize(next);
|
||||
yield next;
|
||||
continue;
|
||||
}
|
||||
// Backpressure-terminated client: yield the synthetic terminator.
|
||||
if (client.backpressured) {
|
||||
yield { type: 'stop', finish_reason: 'length' };
|
||||
yield '[DONE]';
|
||||
client.done = true;
|
||||
return;
|
||||
}
|
||||
// Source already errored.
|
||||
if (client.error) {
|
||||
throw client.error;
|
||||
}
|
||||
// Source already completed and no more queued chunks.
|
||||
if (client.done) {
|
||||
return;
|
||||
}
|
||||
// Block until the tee task pushes the next chunk (or signals
|
||||
// source-done / source-error / backpressure).
|
||||
await new Promise((resolve, reject) => {
|
||||
client.resolveNext = resolve;
|
||||
client.rejectNext = reject;
|
||||
});
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
}
|
||||
} finally {
|
||||
// Iterator return() fired (HTTP close, break, or normal return) —
|
||||
// remove client and possibly trigger source abort.
|
||||
if (entry.attachedClients.has(client)) {
|
||||
entry.attachedClients.delete(client);
|
||||
}
|
||||
// If we're the last client AND the source is still running, fire
|
||||
// the AbortController so the source iterator's return() / cleanup
|
||||
// can reap any underlying resources.
|
||||
if (
|
||||
entry.attachedClients.size === 0
|
||||
&& !entry.sourceDone
|
||||
&& !entry.sourceError
|
||||
&& !entry.sourceAborted
|
||||
&& !entry.factoryPending
|
||||
) {
|
||||
entry.sourceAborted = true;
|
||||
try {
|
||||
entry.sourceAbortController.abort();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
// Tee task observes attachedClients.size === 0 + sourceAborted on
|
||||
// its next loop iteration and exits without a cache write.
|
||||
store._streamingInflight.delete(entry.compositeKey);
|
||||
store._warnFn('streaming_inflight_abort', {
|
||||
composite_key: entry.compositeKey,
|
||||
accumulated_chunk_count: entry.accumulatedChunks.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return iterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* D57 — ADR 0005 Amendment 8 §4: tee fan-out task. One reader pulls from
|
||||
* `entry.source`; on each chunk, pushes to `accumulatedChunks` (bounded by
|
||||
* §10) and to every attached client's queue (per-client cap from §8).
|
||||
*
|
||||
* On source completion: writes accumulated chunks to cache if (a) cap not
|
||||
* exceeded and (b) `set()`'s own `maxEntryBytes` cap admits it. Resolves
|
||||
* all clients to drain-out state. Removes entry.
|
||||
*
|
||||
* On source error: rejects all clients via their `rejectNext`. No cache
|
||||
* write. Removes entry.
|
||||
*
|
||||
* Source-abort short-circuit: if `attachedClients.size === 0` after a push,
|
||||
* fires `sourceAbortController.abort()`, exits without cache write.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_teeStreamingSource(keyId, cacheKey, entry, { accumulatedReplayCap }) {
|
||||
const store = this;
|
||||
(async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
// Pre-check: if all clients have already gone away before we even
|
||||
// pull the next chunk, abort the source and bail out. (The
|
||||
// per-client iterator's finally-block sets sourceAborted=true and
|
||||
// removes the entry; we just need to stop pulling.)
|
||||
if (entry.attachedClients.size === 0 && !entry.factoryPending) {
|
||||
if (!entry.sourceAborted) {
|
||||
entry.sourceAborted = true;
|
||||
try { entry.sourceAbortController.abort(); } catch { /* best-effort */ }
|
||||
}
|
||||
// Try to call return() on the source so the underlying generator
|
||||
// cleans up. Best-effort; not all iterators implement it.
|
||||
try {
|
||||
if (entry.source && typeof entry.source.return === 'function') {
|
||||
await entry.source.return();
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await entry.source.next();
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
const size = _chunkByteSize(chunk);
|
||||
|
||||
// §10 — replay buffer cap. Past the cap we stop accumulating (so
|
||||
// future late joiners can still see the chunks they need to
|
||||
// catch up to live), but we mark the entry not-cacheable so the
|
||||
// §4 completion path skips the cache write.
|
||||
if (!entry.accumulatedReplayCapExceeded) {
|
||||
if (entry.accumulatedByteSize + size > accumulatedReplayCap) {
|
||||
entry.accumulatedReplayCapExceeded = true;
|
||||
store._warnFn('streaming_inflight_replay_cap_exceeded', {
|
||||
composite_key: entry.compositeKey,
|
||||
accumulated_byte_size: entry.accumulatedByteSize,
|
||||
chunk_size: size,
|
||||
accumulated_replay_cap: accumulatedReplayCap,
|
||||
});
|
||||
// Continue accumulating up to this chunk so existing late
|
||||
// joiners' drain decision was based on the size they saw at
|
||||
// attach-time. We do NOT push this chunk to accumulatedChunks
|
||||
// (it would corrupt the "<= cap at attach-time" invariant for
|
||||
// future joiners). Future joiners arriving past this point
|
||||
// see accumulatedByteSize already > cap and get the
|
||||
// backpressure terminator at attach-time per §5.
|
||||
} else {
|
||||
entry.accumulatedChunks.push(chunk);
|
||||
entry.accumulatedByteSize += size;
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out to each client synchronously (no await inside the for-
|
||||
// each-client loop). Disconnecting a client is mutation-during-
|
||||
// iteration; we snapshot the set first.
|
||||
const clientsSnapshot = [...entry.attachedClients];
|
||||
for (const client of clientsSnapshot) {
|
||||
// Per-client backpressure (§8). If the push would exceed the
|
||||
// per-client cap, disconnect this client only.
|
||||
if (client.queueByteSize + size > client.__perClientQueueCap__) {
|
||||
client.backpressured = true;
|
||||
store._warnFn('stream_backpressure_disconnect', {
|
||||
client_id: client.id,
|
||||
queue_byte_size: client.queueByteSize,
|
||||
per_client_cap: client.__perClientQueueCap__,
|
||||
composite_key: entry.compositeKey,
|
||||
reason: 'queue_overflow',
|
||||
});
|
||||
// Remove from set so future fan-out skips this client.
|
||||
entry.attachedClients.delete(client);
|
||||
// Wake the client's pull-promise so it can yield the synthetic
|
||||
// STREAM_BACKPRESSURE terminator.
|
||||
if (client.resolveNext) {
|
||||
const r = client.resolveNext;
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
r({ value: undefined, done: false });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
client.queue.push(chunk);
|
||||
client.queueByteSize += size;
|
||||
if (client.resolveNext) {
|
||||
const r = client.resolveNext;
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
r({ value: undefined, done: false });
|
||||
}
|
||||
}
|
||||
|
||||
// If the fan-out emptied the attached set (all over-cap), the
|
||||
// top-of-loop pre-check will fire on the next iteration and abort.
|
||||
}
|
||||
|
||||
// Source iterator returned normally.
|
||||
entry.sourceDone = true;
|
||||
const cacheWritten =
|
||||
!entry.accumulatedReplayCapExceeded
|
||||
&& entry.accumulatedChunks.length > 0;
|
||||
if (cacheWritten) {
|
||||
// ADR 0005 Amendment 8 §4: write accumulated chunks to cache via
|
||||
// the standard set() path (which itself applies the D23
|
||||
// maxEntryBytes cap — separate from the §10 replay cap).
|
||||
await store.set(keyId, cacheKey, entry.accumulatedChunks, entry.ttlMs);
|
||||
}
|
||||
store._warnFn('streaming_inflight_source_done', {
|
||||
composite_key: entry.compositeKey,
|
||||
attached_count: entry.attachedClients.size,
|
||||
accumulated_chunk_count: entry.accumulatedChunks.length,
|
||||
cache_written: cacheWritten,
|
||||
});
|
||||
// Wake every remaining attached client so they drain their queue and
|
||||
// observe `done = true`.
|
||||
for (const client of [...entry.attachedClients]) {
|
||||
client.done = true;
|
||||
if (client.resolveNext) {
|
||||
const r = client.resolveNext;
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
r({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
store._streamingInflight.delete(entry.compositeKey);
|
||||
} catch (err) {
|
||||
// Source threw mid-stream. Reject every attached client.
|
||||
entry.sourceError = err;
|
||||
for (const client of [...entry.attachedClients]) {
|
||||
client.error = err;
|
||||
client.done = true;
|
||||
if (client.rejectNext) {
|
||||
const rej = client.rejectNext;
|
||||
client.resolveNext = null;
|
||||
client.rejectNext = null;
|
||||
rej(err);
|
||||
}
|
||||
}
|
||||
store._streamingInflight.delete(entry.compositeKey);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stats for a specific keyId, or aggregate stats across all keyIds.
|
||||
*
|
||||
@@ -322,11 +883,14 @@ export class CacheStore {
|
||||
const s = this._stats.get(keyId) ?? { hits: 0, misses: 0 };
|
||||
const ns = this._store.get(keyId);
|
||||
const size = ns ? ns.size : 0;
|
||||
// D57 — ADR 0005 Amendment 8 §1: inflightCount aggregates both the
|
||||
// buffered-path singleflight Map and the streaming-path inflight Map
|
||||
// so stats() reflects all active dedup-coordination entries.
|
||||
return {
|
||||
hits: s.hits,
|
||||
misses: s.misses,
|
||||
size,
|
||||
inflightCount: this._inflight.size,
|
||||
inflightCount: this._inflight.size + this._streamingInflight.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,7 +909,8 @@ export class CacheStore {
|
||||
hits: totalHits,
|
||||
misses: totalMisses,
|
||||
size: totalSize,
|
||||
inflightCount: this._inflight.size,
|
||||
// D57 — see per-keyId branch above; streaming entries counted alongside buffered.
|
||||
inflightCount: this._inflight.size + this._streamingInflight.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,10 +962,18 @@ export class CacheStore {
|
||||
this._inflight.delete(k);
|
||||
}
|
||||
}
|
||||
// D57 — also clear streaming inflight entries scoped to this keyId.
|
||||
// Composite key uses `\0` separator (see _streamingInflight init).
|
||||
for (const k of this._streamingInflight.keys()) {
|
||||
if (k.startsWith(`${keyId}\0`)) {
|
||||
this._streamingInflight.delete(k);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._store.clear();
|
||||
this._stats.clear();
|
||||
this._inflight.clear();
|
||||
this._streamingInflight.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* lib/doctor.mjs — OLP doctor framework (Phase 4 / D65)
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D64-D67 + ADR 0002 Amendment 7 (D67) —
|
||||
* per-provider `doctorChecks()` contract method that this framework consumes.
|
||||
*
|
||||
* `olp doctor` runs a set of `Check` objects. Each check has:
|
||||
* - id: string (unique, e.g. 'server.running', 'anthropic.cli_available')
|
||||
* - category: 'server'|'auth'|'config'|'provider'|'system'
|
||||
* - async run(): { status: 'ok'|'fail'|'warn', message, evidence? }
|
||||
*
|
||||
* Built-in checks (categories server / auth / config / system) are defined in
|
||||
* `buildBuiltinChecks()` below; per-provider checks are sourced from each loaded
|
||||
* plugin's optional `doctorChecks()` method (ADR 0002 Amendment 7).
|
||||
*
|
||||
* Output shape (machine-readable — consumed by `bin/olp.mjs --json`):
|
||||
* {
|
||||
* schema_version: 1,
|
||||
* generated_at: '2026-05-26T...',
|
||||
* checks: [{ id, category, status, message, evidence? }],
|
||||
* fail_count: number,
|
||||
* warn_count: number,
|
||||
* kind: 'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install',
|
||||
* next_action: { ai_executable: string[], human_required: string[], verify: string },
|
||||
* summary: string,
|
||||
* }
|
||||
*
|
||||
* `kind` precedence (highest first — the most upstream blocker wins):
|
||||
* 1. fresh_install — config.exists FAIL (~/.olp/config.json missing/malformed)
|
||||
* 2. fix_server — server.running FAIL
|
||||
* 3. fix_oauth — auth.owner_key_exists FAIL
|
||||
* 4. fix_provider — any provider-category FAIL
|
||||
* 5. fix_config — any other config-category FAIL
|
||||
* 6. noop — all OK (or WARN-only)
|
||||
*
|
||||
* `next_action.ai_executable[]` aggregates `evidence.fix_commands[]` from every
|
||||
* FAIL check; `next_action.human_required[]` aggregates `evidence.human_steps[]`.
|
||||
* `verify` is always `olp doctor` (re-run after applying the fix).
|
||||
*
|
||||
* Design notes:
|
||||
* - Pure functions + dependency injection: callers pass `{ checks }` (which can
|
||||
* be overridden for tests) plus a `{ now }` clock for deterministic timestamps.
|
||||
* - No filesystem writes. No process.exit. No console.log. Callers handle I/O.
|
||||
* - All checks run in parallel via Promise.all — individual check failures are
|
||||
* captured (not propagated) so one broken check does not hide others.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
|
||||
import { loadProviders } from './providers/index.mjs';
|
||||
import { loadFallbackConfigSync } from './fallback/engine.mjs';
|
||||
import { listKeys } from './keys.mjs';
|
||||
|
||||
// ── Schema version ────────────────────────────────────────────────────────
|
||||
|
||||
export const DOCTOR_SCHEMA_VERSION = 1;
|
||||
|
||||
// ── Built-in check builders ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the OLP base URL the CLI / doctor will probe.
|
||||
* Precedence:
|
||||
* 1. opts.proxyUrl (explicit caller override)
|
||||
* 2. OLP_PROXY_URL env (full URL like http://host:port)
|
||||
* 3. http://127.0.0.1:${OLP_PORT || 4567}
|
||||
*/
|
||||
export function resolveProxyUrl(opts = {}) {
|
||||
if (opts.proxyUrl) return String(opts.proxyUrl).replace(/\/+$/, '');
|
||||
if (process.env.OLP_PROXY_URL) return String(process.env.OLP_PROXY_URL).replace(/\/+$/, '');
|
||||
const port = process.env.OLP_PORT ?? '4567';
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OLP_HOME directory (mirrors lib/keys.mjs precedence).
|
||||
* 1. opts.olpHome
|
||||
* 2. OLP_HOME env
|
||||
* 3. ~/.olp
|
||||
*/
|
||||
export function resolveOlpHome(opts = {}) {
|
||||
if (opts.olpHome) return opts.olpHome;
|
||||
if (process.env.OLP_HOME) return process.env.OLP_HOME;
|
||||
return join(homedir(), '.olp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper — issue a GET to the proxy with a tight timeout. Returns
|
||||
* `{ ok: true, status, body }` or `{ ok: false, error }`. Never throws.
|
||||
*/
|
||||
async function httpGet(url, { timeoutMs = 3000, headers = {} } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
let req;
|
||||
try {
|
||||
req = httpRequest(url, { method: 'GET', headers, timeout: timeoutMs }, res => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
|
||||
});
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: String(e?.message ?? e) });
|
||||
return;
|
||||
}
|
||||
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e) }));
|
||||
req.on('timeout', () => {
|
||||
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default check set. Test-mode overrides:
|
||||
* - opts.injectChecks: [...Check] — REPLACES the built-in set entirely
|
||||
* - opts.providersOverride: Map<name, plugin> — REPLACES loaded providers for the per-provider sweep
|
||||
* - opts.skipNetwork: true → omit server.running / server.version (offline mode)
|
||||
* - opts.olpHome: override ~/.olp lookup
|
||||
* - opts.proxyUrl: override resolveProxyUrl()
|
||||
*/
|
||||
/**
|
||||
* D64-D67 reviewer P2-1: shell-quote a path before interpolation into
|
||||
* `ai_executable[]` strings. Single-quote-wrap + escape any embedded single
|
||||
* quote per POSIX shell rules: `foo'bar` → `'foo'\''bar'`. Defends against
|
||||
* a malicious `OLP_HOME` env value injecting shell metacharacters into the
|
||||
* suggested-fix command an AI agent (or human) might paste back.
|
||||
*
|
||||
* Risk surface is narrow at family scale (operator local env, single-user
|
||||
* proxy), but the hardening cost is one helper.
|
||||
*
|
||||
* @param {string} s
|
||||
* @returns {string} single-quoted shell-safe string
|
||||
*/
|
||||
function _shellQuote(s) {
|
||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function buildBuiltinChecks(opts = {}) {
|
||||
if (opts.injectChecks) return opts.injectChecks;
|
||||
|
||||
const olpHome = resolveOlpHome(opts);
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
const proxyUrl = resolveProxyUrl(opts);
|
||||
// D74 P1-1 fix: server.running and server.version probe /health, which
|
||||
// under default production posture (auth.allow_anonymous: false) requires
|
||||
// an Authorization: Bearer header. Without it, the probe gets 401 and
|
||||
// doctor falsely reports the server down. Caller passes the resolved
|
||||
// bearer token via opts.authHeaders (a `{Authorization: 'Bearer ...'}`
|
||||
// object). Empty headers means "no token configured" — the probe still
|
||||
// fires but a 401 response is treated as "auth misconfigured" rather
|
||||
// than "server down" (see server.running check below).
|
||||
const authHeaders = opts.authHeaders ?? {};
|
||||
|
||||
const checks = [];
|
||||
|
||||
// ── system.* ─────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'system.node_version',
|
||||
category: 'system',
|
||||
async run() {
|
||||
// package.json engines.node = >=18; check process.versions.node major >= 18
|
||||
const major = parseInt(String(process.versions.node).split('.')[0], 10);
|
||||
if (Number.isFinite(major) && major >= 18) {
|
||||
return { status: 'ok', message: `Node ${process.versions.node} (>=18)` };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `Node ${process.versions.node} is below the required >=18 (per package.json engines.node)`,
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'Install a current Node.js LTS (>=18) — see https://nodejs.org/en/download',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── config.* ─────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'config.exists',
|
||||
category: 'config',
|
||||
async run() {
|
||||
if (!existsSync(configPath)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${configPath} not found`,
|
||||
evidence: {
|
||||
// D64-D67 reviewer P2-1: shell-quote paths via _shellQuote so a
|
||||
// malicious OLP_HOME env can't inject shell metacharacters into
|
||||
// the suggested-fix command pasted into an AI agent.
|
||||
fix_commands: [
|
||||
`mkdir -p ${_shellQuote(olpHome)}`,
|
||||
`printf '%s\\n' '{"auth":{"allow_anonymous":false,"owner_only_endpoints":["/health"],"fallback_detail_header_policy":"owner_only"},"providers":{"enabled":{}},"routing":{"chains":{},"soft_triggers":{}},"streaming":{"heartbeat_interval_ms":0}}' > ${_shellQuote(configPath)}`,
|
||||
],
|
||||
reference: 'docs/adr/0007-multi-key-auth.md § 3, docs/adr/0004-fallback-engine.md',
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return { status: 'ok', message: `${configPath} parses` };
|
||||
}
|
||||
return { status: 'fail', message: `${configPath} parses but is not a JSON object` };
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${configPath} unreadable / malformed: ${e?.message ?? e}`,
|
||||
evidence: {
|
||||
human_steps: [
|
||||
`Inspect ${configPath} — fix the JSON syntax error (or delete the file to fall back to the empty default and re-run olp doctor)`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'config.providers_enabled',
|
||||
category: 'config',
|
||||
async run() {
|
||||
try {
|
||||
const cfg = loadFallbackConfigSync(configPath);
|
||||
const enabled = cfg.providersEnabled ?? {};
|
||||
const enabledNames = Object.keys(enabled).filter(k => enabled[k] === true);
|
||||
if (enabledNames.length > 0) {
|
||||
return { status: 'ok', message: `${enabledNames.length} provider(s) enabled: ${enabledNames.join(', ')}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: 'No providers enabled in config.json (all /v1/chat/completions requests will 503)',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
`Edit ${configPath} → set providers.enabled.<name> = true for at least one provider (anthropic / openai / mistral)`,
|
||||
],
|
||||
reference: 'docs/adr/0002-plugin-architecture.md § Disable model',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `Could not read providers.enabled from ${configPath}: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'config.chains_configured',
|
||||
category: 'config',
|
||||
async run() {
|
||||
try {
|
||||
const cfg = loadFallbackConfigSync(configPath);
|
||||
const chains = cfg.chains ?? {};
|
||||
const chainNames = Object.keys(chains);
|
||||
if (chainNames.length > 0) {
|
||||
return { status: 'ok', message: `${chainNames.length} chain(s) configured: ${chainNames.join(', ')}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: 'No routing chains configured (single-hop mode; cross-provider fallback inactive)',
|
||||
evidence: {
|
||||
reference: 'docs/adr/0004-fallback-engine.md § Chain configuration',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `Could not read routing.chains from ${configPath}: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── auth.* ───────────────────────────────────────────────────────────
|
||||
checks.push({
|
||||
id: 'auth.owner_key_exists',
|
||||
category: 'auth',
|
||||
async run() {
|
||||
// Per ADR 0007 § 9.4: process.env.OLP_OWNER_TOKEN also satisfies "owner identity".
|
||||
if (process.env.OLP_OWNER_TOKEN) {
|
||||
return { status: 'ok', message: 'OLP_OWNER_TOKEN env var present (synthetic env-owner per ADR 0007 § 9.4)' };
|
||||
}
|
||||
try {
|
||||
const keys = listKeys({ olpHome });
|
||||
const activeOwner = keys.find(k => k.owner_tier === 'owner' && k.revoked_at === null);
|
||||
if (activeOwner) {
|
||||
return { status: 'ok', message: `active owner key found: id=${activeOwner.id} name="${activeOwner.name}"` };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'No active owner-tier key found in ~/.olp/keys/ and OLP_OWNER_TOKEN env unset',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp-keys keygen --owner',
|
||||
],
|
||||
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 (bootstrap & recovery)',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'fail', message: `listKeys failed: ${e?.message ?? e}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── server.* ─────────────────────────────────────────────────────────
|
||||
if (!opts.skipNetwork) {
|
||||
checks.push({
|
||||
id: 'server.running',
|
||||
category: 'server',
|
||||
async run() {
|
||||
// D74 P1-1: pass authHeaders so the probe works under the default
|
||||
// production posture (auth.allow_anonymous: false).
|
||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
|
||||
if (!r.ok) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${proxyUrl}/health unreachable: ${r.error}`,
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp restart',
|
||||
],
|
||||
reference: 'README.md § Running OLP',
|
||||
},
|
||||
};
|
||||
}
|
||||
// 401: server is up but the caller has no/wrong bearer token. NOT a
|
||||
// "server down" condition — distinguish so the kind discriminator
|
||||
// doesn't route to fix_server when the user just needs OLP_API_KEY.
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
return {
|
||||
status: 'fail',
|
||||
message: `${proxyUrl}/health returned ${r.status} — server is up but the bearer token is missing or invalid. Set OLP_API_KEY env to an owner-tier token (npx olp-keys list).`,
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'echo "set OLP_API_KEY=<your owner token> or OLP_OWNER_TOKEN=<...> then rerun olp doctor"',
|
||||
],
|
||||
human_required: [
|
||||
'Locate an owner-tier OLP API key plaintext (or run `npx olp-keys keygen --owner` to mint a new one — printed ONCE).',
|
||||
'Export it: `export OLP_API_KEY=olp_...`',
|
||||
],
|
||||
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 + README § Environment Variables',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (r.status !== 200) {
|
||||
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
|
||||
}
|
||||
return { status: 'ok', message: `${proxyUrl}/health → 200` };
|
||||
},
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: 'server.version',
|
||||
category: 'server',
|
||||
async run() {
|
||||
// Read local package.json version
|
||||
let localVersion = null;
|
||||
try {
|
||||
// Resolve relative to this file — lib/doctor.mjs → ../package.json
|
||||
// import.meta.url gives a file:// URL; convert and join.
|
||||
const here = new URL('../package.json', import.meta.url);
|
||||
const pkg = JSON.parse(readFileSync(here, 'utf8'));
|
||||
localVersion = pkg.version ?? null;
|
||||
} catch {
|
||||
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
|
||||
}
|
||||
// D74 P1-1: same auth-headers fix as server.running.
|
||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
|
||||
if (!r.ok || r.status !== 200) {
|
||||
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
|
||||
}
|
||||
let serverVersion = null;
|
||||
try {
|
||||
serverVersion = JSON.parse(r.body)?.version ?? null;
|
||||
} catch {
|
||||
return { status: 'warn', message: '/health returned non-JSON; cannot compare version' };
|
||||
}
|
||||
if (!serverVersion) {
|
||||
return { status: 'warn', message: '/health did not include version; cannot compare' };
|
||||
}
|
||||
if (serverVersion === localVersion) {
|
||||
return { status: 'ok', message: `local v${localVersion} matches running v${serverVersion}` };
|
||||
}
|
||||
return {
|
||||
status: 'warn',
|
||||
message: `local v${localVersion} differs from running v${serverVersion} — restart to pick up the new code`,
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npx olp restart',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep loaded providers for doctorChecks() (ADR 0002 Amendment 7).
|
||||
* Plugins without doctorChecks() contribute nothing (default — back-compat).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Map} [opts.providersOverride] — Map<name, plugin> for tests
|
||||
* @param {object} [opts.providersEnabled] — Record<string, boolean>; default = all from config.json
|
||||
* @returns {Check[]}
|
||||
*/
|
||||
export function collectProviderChecks(opts = {}) {
|
||||
let providers;
|
||||
if (opts.providersOverride) {
|
||||
providers = opts.providersOverride;
|
||||
} else {
|
||||
const olpHome = resolveOlpHome(opts);
|
||||
const configPath = join(olpHome, 'config.json');
|
||||
let enabled = opts.providersEnabled;
|
||||
if (!enabled) {
|
||||
try {
|
||||
enabled = loadFallbackConfigSync(configPath).providersEnabled ?? {};
|
||||
} catch {
|
||||
enabled = {};
|
||||
}
|
||||
}
|
||||
providers = loadProviders({ enabled });
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
for (const [_name, plugin] of providers) {
|
||||
if (typeof plugin?.doctorChecks !== 'function') continue;
|
||||
let pluginChecks;
|
||||
try {
|
||||
pluginChecks = plugin.doctorChecks();
|
||||
} catch (e) {
|
||||
// Misbehaving plugin — surface as a synthesized fail check, do not crash.
|
||||
checks.push({
|
||||
id: `${plugin.name}.doctor_checks_threw`,
|
||||
category: 'provider',
|
||||
async run() {
|
||||
return { status: 'fail', message: `doctorChecks() threw: ${e?.message ?? e}` };
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(pluginChecks)) continue;
|
||||
for (const c of pluginChecks) {
|
||||
if (c && typeof c.id === 'string' && typeof c.run === 'function') {
|
||||
checks.push({
|
||||
id: c.id,
|
||||
category: c.category ?? 'provider',
|
||||
run: c.run,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
// ── Discriminator (kind precedence) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a flat results array, determine the next-action discriminator.
|
||||
* Per ADR 0010 § D65 framework:
|
||||
* fresh_install > fix_server > fix_oauth > fix_provider > fix_config > noop
|
||||
*/
|
||||
export function deriveKind(results) {
|
||||
const failed = results.filter(r => r.status === 'fail');
|
||||
if (failed.length === 0) return 'noop';
|
||||
|
||||
if (failed.some(r => r.id === 'config.exists')) return 'fresh_install';
|
||||
if (failed.some(r => r.category === 'server')) return 'fix_server';
|
||||
if (failed.some(r => r.category === 'auth')) return 'fix_oauth';
|
||||
if (failed.some(r => r.category === 'provider')) return 'fix_provider';
|
||||
if (failed.some(r => r.category === 'config')) return 'fix_config';
|
||||
return 'fix_config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the next_action block from FAIL results' evidence.
|
||||
*/
|
||||
export function deriveNextAction(results, kind) {
|
||||
const ai_executable = [];
|
||||
const human_required = [];
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fail') continue;
|
||||
const ev = r.evidence ?? {};
|
||||
if (Array.isArray(ev.fix_commands)) ai_executable.push(...ev.fix_commands);
|
||||
if (Array.isArray(ev.human_steps)) human_required.push(...ev.human_steps);
|
||||
}
|
||||
return {
|
||||
ai_executable,
|
||||
human_required,
|
||||
verify: kind === 'noop' ? 'already healthy' : 'olp doctor',
|
||||
};
|
||||
}
|
||||
|
||||
// ── runDoctor (main entry) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute every check in parallel; aggregate; derive kind + next_action.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {Check[]} [opts.injectChecks] — REPLACE the built-in + provider checks entirely
|
||||
* @param {Check[]} [opts.extraChecks] — APPEND extra checks (after defaults)
|
||||
* @param {string} [opts.checkFilter] — restrict to checks whose id OR category matches
|
||||
* @param {Map} [opts.providersOverride] — for the per-provider sweep
|
||||
* @param {object} [opts.providersEnabled] — Record<string, boolean>
|
||||
* @param {string} [opts.olpHome] — override ~/.olp
|
||||
* @param {string} [opts.proxyUrl] — override the proxy URL
|
||||
* @param {boolean} [opts.skipNetwork] — omit server.* checks
|
||||
* @param {() => Date} [opts.now] — clock injection
|
||||
* @returns {Promise<DoctorResult>}
|
||||
*/
|
||||
export async function runDoctor(opts = {}) {
|
||||
const now = opts.now ?? (() => new Date());
|
||||
|
||||
let checks;
|
||||
if (opts.injectChecks) {
|
||||
checks = [...opts.injectChecks];
|
||||
} else {
|
||||
checks = [
|
||||
...buildBuiltinChecks(opts),
|
||||
...collectProviderChecks(opts),
|
||||
];
|
||||
}
|
||||
if (opts.extraChecks) checks.push(...opts.extraChecks);
|
||||
|
||||
// --check <filter>: restrict to checks whose id OR category startsWith / equals the filter.
|
||||
// Match rule: exact id match, exact category match, OR id startsWith `<filter>.`
|
||||
// (so --check anthropic matches both anthropic.cli_available and anthropic.oauth_token_present).
|
||||
if (opts.checkFilter) {
|
||||
const f = String(opts.checkFilter);
|
||||
checks = checks.filter(c =>
|
||||
c.id === f
|
||||
|| c.category === f
|
||||
|| c.id.startsWith(`${f}.`)
|
||||
);
|
||||
}
|
||||
|
||||
// Run all checks in parallel. Capture per-check failures (do not let one throw
|
||||
// hide the rest of the diagnostic).
|
||||
const results = await Promise.all(checks.map(async c => {
|
||||
try {
|
||||
const r = await c.run();
|
||||
return {
|
||||
id: c.id,
|
||||
category: c.category,
|
||||
status: r?.status ?? 'fail',
|
||||
message: r?.message ?? '(check returned no message)',
|
||||
...(r?.evidence !== undefined ? { evidence: r.evidence } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: c.id,
|
||||
category: c.category,
|
||||
status: 'fail',
|
||||
message: `check threw: ${e?.message ?? e}`,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const fail_count = results.filter(r => r.status === 'fail').length;
|
||||
const warn_count = results.filter(r => r.status === 'warn').length;
|
||||
const ok_count = results.filter(r => r.status === 'ok').length;
|
||||
const kind = deriveKind(results);
|
||||
const next_action = deriveNextAction(results, kind);
|
||||
|
||||
let summary;
|
||||
if (fail_count === 0 && warn_count === 0) {
|
||||
summary = `all ${ok_count} checks ok`;
|
||||
} else if (fail_count === 0) {
|
||||
summary = `${ok_count} ok, ${warn_count} warn — no FAIL; kind=${kind}`;
|
||||
} else {
|
||||
const firstFail = results.find(r => r.status === 'fail');
|
||||
summary = `${fail_count} of ${results.length} checks failed — ${firstFail?.id ?? '?'} (${firstFail?.message?.slice(0, 80) ?? ''})`;
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: DOCTOR_SCHEMA_VERSION,
|
||||
generated_at: now().toISOString(),
|
||||
checks: results,
|
||||
fail_count,
|
||||
warn_count,
|
||||
ok_count,
|
||||
kind,
|
||||
next_action,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Check
|
||||
* @property {string} id
|
||||
* @property {'server'|'auth'|'config'|'provider'|'system'} category
|
||||
* @property {() => Promise<{ status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }>} run
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DoctorResult
|
||||
* @property {number} schema_version
|
||||
* @property {string} generated_at (ISO timestamp)
|
||||
* @property {Array<{ id: string, category: string, status: 'ok'|'fail'|'warn', message: string, evidence?: object }>} checks
|
||||
* @property {number} fail_count
|
||||
* @property {number} warn_count
|
||||
* @property {number} ok_count
|
||||
* @property {'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install'} kind
|
||||
* @property {{ ai_executable: string[], human_required: string[], verify: string }} next_action
|
||||
* @property {string} summary
|
||||
*/
|
||||
+14
-2
@@ -667,24 +667,36 @@ function defaultConfigPath() {
|
||||
* Returns empty config (no chains, no soft triggers, no enabled providers) if the
|
||||
* file is absent, unreadable, or malformed.
|
||||
*
|
||||
* D61 (ADR 0010 § Phase 4 D61-D63): adds `streaming` block. Currently
|
||||
* exposes `heartbeat_interval_ms` (default 0 = heartbeat disabled). When
|
||||
* heartbeat_interval_ms > 0, the streaming branch emits `: keepalive\n\n`
|
||||
* SSE comment frames during silent windows of length >= the interval. Default
|
||||
* 0 preserves backwards compat (no behavioural change).
|
||||
*
|
||||
* @param {string} [configPath] — override path (for testing — do NOT write to ~/.olp/config.json in tests)
|
||||
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean> }}
|
||||
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean>, streaming: { heartbeat_interval_ms: number } }}
|
||||
*/
|
||||
export function loadFallbackConfigSync(configPath) {
|
||||
const DEFAULT_STREAMING = { heartbeat_interval_ms: 0 };
|
||||
try {
|
||||
const path = configPath ?? defaultConfigPath();
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const routing = parsed?.routing ?? {};
|
||||
const providers = parsed?.providers ?? {};
|
||||
const streaming = parsed?.streaming ?? {};
|
||||
const hb = Number(streaming.heartbeat_interval_ms);
|
||||
return {
|
||||
chains: routing.chains ?? {},
|
||||
soft_triggers: routing.soft_triggers ?? {},
|
||||
providersEnabled: providers.enabled ?? {},
|
||||
streaming: {
|
||||
heartbeat_interval_ms: Number.isFinite(hb) && hb >= 0 ? hb : 0,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
|
||||
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
|
||||
return { chains: {}, soft_triggers: {}, providersEnabled: {} };
|
||||
return { chains: {}, soft_triggers: {}, providersEnabled: {}, streaming: { ...DEFAULT_STREAMING } };
|
||||
}
|
||||
}
|
||||
|
||||
+75
-4
@@ -249,7 +249,7 @@ async function _withKeyLock(id, fn) {
|
||||
* never logged. The manifest contains only the hash.
|
||||
*/
|
||||
export function createKey(args = {}) {
|
||||
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome } = args;
|
||||
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome, plaintext_advertise = false } = args;
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new Error('createKey: name is required (non-empty string)');
|
||||
}
|
||||
@@ -259,6 +259,15 @@ export function createKey(args = {}) {
|
||||
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
|
||||
throw new Error('createKey: providers_enabled must be "*" or string array');
|
||||
}
|
||||
// D69 plaintext_advertise (ADR 0011): only valid on guest tier — see ADR
|
||||
// 0011 § "Trusted-LAN invariant + tier restriction". Owner-tier advertisement
|
||||
// is rejected because exposing the owner identity unauthenticated would
|
||||
// grant unauthenticated callers /health full payload, /v0/management/* access,
|
||||
// and X-OLP-Fallback-Detail visibility — the inverse of the advertise key's
|
||||
// intent (a low-privilege zero-config tier).
|
||||
if (plaintext_advertise && owner_tier !== 'guest') {
|
||||
throw new Error('createKey: plaintext_advertise requires owner_tier="guest" (ADR 0011)');
|
||||
}
|
||||
|
||||
const id = generateKeyId();
|
||||
const plaintext_token = generateToken();
|
||||
@@ -276,10 +285,59 @@ export function createKey(args = {}) {
|
||||
last_used_at: null,
|
||||
notes,
|
||||
};
|
||||
// D69 (ADR 0011): when the operator explicitly opts in via --advertise on
|
||||
// keygen, the plaintext token is co-located with the hash so the server can
|
||||
// surface it via /health.anonymousKey for zero-config family-LAN setup.
|
||||
// This is the ONLY place plaintext ever lands on disk; see ADR 0011 for
|
||||
// the trusted-LAN-only invariant + threat model.
|
||||
if (plaintext_advertise) {
|
||||
manifest.plaintext_advertise = plaintext_token;
|
||||
}
|
||||
writeManifestAtomic(id, manifest, { olpHome });
|
||||
return { id, plaintext_token, manifest };
|
||||
}
|
||||
|
||||
// ── D69 advertise-key discovery (ADR 0011) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the active key marked for /health advertisement. Returns the manifest
|
||||
* (including `plaintext_advertise`) or null when no such key exists.
|
||||
*
|
||||
* Scans every manifest under ~/.olp/keys/; selects the FIRST active
|
||||
* (revoked_at === null) manifest that carries a non-empty `plaintext_advertise`
|
||||
* string. Deterministic ordering is unstable across filesystems — operators
|
||||
* are expected to keep at most one advertised key on disk at a time.
|
||||
*
|
||||
* Returns null if:
|
||||
* - the keys directory doesn't exist
|
||||
* - no manifest carries plaintext_advertise
|
||||
* - the only matching manifest is revoked
|
||||
*
|
||||
* Used by server.mjs handleHealth (D69) + olp-keys CLI 'list' subcommand
|
||||
* (advertise badge).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||
* @returns {object|null} manifest object (NOT redacted; carries plaintext_advertise)
|
||||
*/
|
||||
export function findAdvertisedKey(opts = {}) {
|
||||
const dir = _keysDir(opts);
|
||||
if (!existsSync(dir)) return null;
|
||||
let entries;
|
||||
try { entries = readdirSync(dir); } catch { return null; }
|
||||
for (const id of entries) {
|
||||
if (id.startsWith('.')) continue;
|
||||
let m;
|
||||
try { m = readManifest(id, opts); } catch { continue; }
|
||||
if (m === null) continue;
|
||||
if (m.revoked_at !== null) continue;
|
||||
if (typeof m.plaintext_advertise === 'string' && m.plaintext_advertise.length > 0) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys. Returns array of manifest objects with `token_hash` redacted
|
||||
* (kept on disk; omitted from list output per common operational hygiene —
|
||||
@@ -299,8 +357,14 @@ export function listKeys(opts = {}) {
|
||||
try {
|
||||
const m = readManifest(id, opts);
|
||||
if (m === null) continue;
|
||||
// Redact token_hash from list output (keep on disk).
|
||||
const { token_hash, ...rest } = m;
|
||||
// D69 reviewer P2-1 (footgun-removal): strip BOTH `token_hash` AND
|
||||
// `plaintext_advertise` from list output. Callers wanting the
|
||||
// advertised plaintext for the /health publication path must go
|
||||
// through `findAdvertisedKey()` instead, which is the only sanctioned
|
||||
// read site. Future callers of `listKeys()` that emit results into
|
||||
// logs / HTTP responses / dashboards therefore can't accidentally
|
||||
// leak the advertised plaintext.
|
||||
const { token_hash, plaintext_advertise, ...rest } = m;
|
||||
out.push(rest);
|
||||
} catch {
|
||||
// Skip invalid manifest; production impl would log warn.
|
||||
@@ -456,12 +520,17 @@ export async function touchLastUsed(id, opts = {}) {
|
||||
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
||||
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
|
||||
*
|
||||
* D69 / ADR 0011:
|
||||
* - advertise_anonymous_key: false (default off; opt-in surfaces
|
||||
* findAdvertisedKey() plaintext via
|
||||
* /health.anonymousKey)
|
||||
*
|
||||
* Returns the auth config object. Never throws — missing file / parse
|
||||
* error / missing `auth` key all fall back to defaults.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }}
|
||||
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none', advertise_anonymous_key: boolean }}
|
||||
*/
|
||||
export function loadAuthConfigSync(opts = {}) {
|
||||
const olpHome = _resolveOlpHome(opts);
|
||||
@@ -470,6 +539,7 @@ export function loadAuthConfigSync(opts = {}) {
|
||||
allow_anonymous: false,
|
||||
owner_only_endpoints: ['/health'],
|
||||
fallback_detail_header_policy: 'owner_only',
|
||||
advertise_anonymous_key: false,
|
||||
};
|
||||
if (!existsSync(path)) return { ...DEFAULTS };
|
||||
try {
|
||||
@@ -484,6 +554,7 @@ export function loadAuthConfigSync(opts = {}) {
|
||||
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
||||
? auth.fallback_detail_header_policy
|
||||
: DEFAULTS.fallback_detail_header_policy,
|
||||
advertise_anonymous_key: typeof auth.advertise_anonymous_key === 'boolean' ? auth.advertise_anonymous_key : DEFAULTS.advertise_anonymous_key,
|
||||
};
|
||||
} catch {
|
||||
// Malformed JSON / unreadable file → safe defaults
|
||||
|
||||
@@ -485,6 +485,64 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// Per-plugin probe templates consumed by `olp doctor`. Each check returns
|
||||
// { status, message, evidence? } where evidence.fix_commands[] flow into the
|
||||
// next_action.ai_executable[] block and evidence.human_steps[] into
|
||||
// next_action.human_required[].
|
||||
//
|
||||
// Probes:
|
||||
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
|
||||
// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||
//
|
||||
// Both probes share the existing test seams (binaryExists / readAuthArtifact) so the
|
||||
// suite can stub them deterministically without spawning the real binary.
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'anthropic.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`claude` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`claude` binary not found on PATH (and OLP_CLAUDE_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @anthropic-ai/claude-code',
|
||||
],
|
||||
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'anthropic.oauth_token_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.accessToken) {
|
||||
return { status: 'ok', message: 'OAuth credential present (.credentials.json / env / keychain)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Anthropic OAuth credential missing — none of ANTHROPIC_OAUTH_TOKEN env, ~/.claude/.credentials.json, or login keychain returned a token',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'run: claude (the first interactive launch prompts for browser OAuth login)',
|
||||
],
|
||||
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
|
||||
// contractVersion fold-in per reviewer F3.
|
||||
@@ -509,6 +567,8 @@ const anthropic = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false,
|
||||
concurrentSpawnSafe: true,
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
* collectAllChunks directly. ADR 0002 Amendment 3 (D23).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DoctorCheck
|
||||
* @property {string} id - unique per check, e.g. 'anthropic.cli_available'
|
||||
* @property {'provider'} category - fixed for plugin-contributed checks (ADR 0002 Amendment 7)
|
||||
* @property {function} run - async () => { status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProviderContractV1
|
||||
* @property {string} name - unique lowercase key
|
||||
@@ -41,6 +48,7 @@
|
||||
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
|
||||
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
|
||||
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
|
||||
* @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67)
|
||||
* @property {ProviderHints} hints
|
||||
*/
|
||||
|
||||
@@ -113,6 +121,13 @@ export function validateProvider(p) {
|
||||
errors.push('healthCheck must be a function');
|
||||
}
|
||||
|
||||
// ADR 0002 Amendment 7 (D67): doctorChecks() is optional. When present it must be
|
||||
// a function; absence is allowed (plugin contributes no provider-tier checks to
|
||||
// `olp doctor` — built-in server/system/auth checks still run).
|
||||
if (p.doctorChecks !== undefined && typeof p.doctorChecks !== 'function') {
|
||||
errors.push('doctorChecks must be a function or omitted');
|
||||
}
|
||||
|
||||
if (!p.hints || typeof p.hints !== 'object') {
|
||||
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
|
||||
} else {
|
||||
@@ -162,6 +177,15 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
|
||||
'SPAWN_FAILED',
|
||||
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
'CONCURRENCY_LIMIT', // ADR 0002 Amendment 6 / ADR 0004 Amendment 4 (D38, issue #1)
|
||||
/* ADR 0005 Amendment 8 §8 (D57): per-client streaming queue overflow.
|
||||
* NOT a hard trigger — the source spawned successfully and other attached
|
||||
* clients continue to receive chunks; only this client's queue exceeded
|
||||
* PER_CLIENT_QUEUE_CAP. The affected client receives a synthetic
|
||||
* { type: 'stop', finish_reason: 'length' } + [DONE] terminator.
|
||||
* D58 wires server-side header/log surface; HARD_TRIGGER_CODES in
|
||||
* lib/fallback/engine.mjs is a whitelist so absence here gives the
|
||||
* correct default (no fallback advancement). */
|
||||
'STREAM_BACKPRESSURE',
|
||||
]);
|
||||
|
||||
export class ProviderError extends Error {
|
||||
|
||||
@@ -637,6 +637,59 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
|
||||
//
|
||||
// Probes:
|
||||
// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN)
|
||||
// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||
// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json)
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'openai.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`codex` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @openai/codex',
|
||||
],
|
||||
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'openai.auth_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.accessToken) {
|
||||
return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)',
|
||||
],
|
||||
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||
|
||||
@@ -662,6 +715,8 @@ const codex = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
|
||||
@@ -764,6 +764,60 @@ function _defaultBinaryExists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
|
||||
//
|
||||
// Probes:
|
||||
// mistral.cli_available — `vibe --version` resolves on PATH (or via OLP_VIBE_BIN)
|
||||
// mistral.api_key_present — readAuthArtifact() returns apiKey (MISTRAL_API_KEY env or ~/.vibe/.env)
|
||||
// (DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration —
|
||||
// auth from MISTRAL_API_KEY env / ~/.vibe/.env)
|
||||
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||
const authRead = _authReadFn ?? readAuthArtifact;
|
||||
return [
|
||||
{
|
||||
id: 'mistral.cli_available',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
if (binaryExists()) {
|
||||
return { status: 'ok', message: '`vibe` binary resolved on PATH' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: '`vibe` binary not found on PATH (and OLP_VIBE_BIN unset/invalid)',
|
||||
evidence: {
|
||||
fix_commands: [
|
||||
'npm install -g @mistralai/vibe',
|
||||
],
|
||||
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/quickstart',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mistral.api_key_present',
|
||||
category: 'provider',
|
||||
async run() {
|
||||
const auth = authRead();
|
||||
if (auth?.apiKey) {
|
||||
return { status: 'ok', message: 'Mistral API key present (env MISTRAL_API_KEY or ~/.vibe/.env)' };
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
message: 'Mistral API key missing — neither MISTRAL_API_KEY env nor ~/.vibe/.env (or $VIBE_HOME/.env) supplied a key',
|
||||
evidence: {
|
||||
human_steps: [
|
||||
'export MISTRAL_API_KEY=<your-key> # or write MISTRAL_API_KEY=... into ~/.vibe/.env',
|
||||
],
|
||||
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/configuration',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||
|
||||
@@ -795,6 +849,8 @@ const mistral = {
|
||||
estimateCost,
|
||||
quotaStatus,
|
||||
healthCheck,
|
||||
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||
doctorChecks: () => doctorChecks(),
|
||||
hints: {
|
||||
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# olp-plugin
|
||||
|
||||
OpenClaw gateway plugin that exposes a `/olp` slash command on Telegram and
|
||||
Discord, with subcommand parity to the local `olp` CLI (`bin/olp.mjs`) minus
|
||||
mutating operations.
|
||||
|
||||
**Authority:** [ADR 0010 § Phase 4 D71-D73](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
## Status
|
||||
|
||||
✅ Shipped at v0.4.0 (read-only subset of `olp` CLI).
|
||||
|
||||
## What you can do from chat
|
||||
|
||||
| Slash command | Maps to | Tier |
|
||||
|---|---|---|
|
||||
| `/olp status` | GET `/v0/management/status` | owner |
|
||||
| `/olp health` | GET `/health` | public |
|
||||
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
|
||||
| `/olp models` | GET `/v1/models` | public |
|
||||
| `/olp cache` | GET `/cache/stats` | owner |
|
||||
| `/olp providers` | local registry view | public |
|
||||
| `/olp chain show [model]` | local chain view (empty unless wired) | public |
|
||||
| `/olp doctor` | informational only (HTTP doctor endpoint not yet shipped) | — |
|
||||
| `/olp help` | usage text | — |
|
||||
|
||||
## What you can NOT do from chat (by design)
|
||||
|
||||
The following `olp` CLI subcommands are **deliberately not** ported to the
|
||||
chat surface, because Telegram + Discord are shared / persistent message
|
||||
streams and key material or raw audit logs should not be flowing across
|
||||
them:
|
||||
|
||||
- `olp keys keygen` — key material would land in chat history
|
||||
- `olp keys revoke` — accidental misclick could lock out clients
|
||||
- `olp restart` — a misclick should not cycle the proxy
|
||||
- `olp logs` — audit content may carry PII
|
||||
|
||||
Use SSH to the host running OLP and the local `olp` CLI for those.
|
||||
|
||||
## Install
|
||||
|
||||
The plugin is shipped inside the OLP repo at `olp-plugin/`. Two install paths:
|
||||
|
||||
### Option A — OpenClaw CLI
|
||||
|
||||
```bash
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
```
|
||||
|
||||
### Option B — symlink
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
Either path makes the plugin discoverable; restart the gateway to pick it up:
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Edit `~/.openclaw/openclaw.json` and add a config block for the `olp` plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `proxyUrl` — full URL of the OLP proxy. Default `http://127.0.0.1:4567`
|
||||
(OLP's default since v0.4.0 / D60). Overridable via `OLP_PROXY_URL` or
|
||||
`OLP_PORT` env if you run the gateway under launchd / systemd with custom
|
||||
env.
|
||||
- `apiKey` — **owner-tier** OLP API key. Required for the subcommands marked
|
||||
`owner` in the table above. Create one with:
|
||||
|
||||
```bash
|
||||
# On the OLP host, NOT in chat:
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
# Capture the plaintext token from the output — it is printed exactly once.
|
||||
```
|
||||
|
||||
Use a dedicated bot key (the `--name=openclaw-bot` example above) so you
|
||||
can `npx olp-keys revoke --id=<id>` later without affecting the
|
||||
maintainer's personal key.
|
||||
|
||||
## Use
|
||||
|
||||
In Telegram or Discord, after the gateway picks up the plugin:
|
||||
|
||||
```
|
||||
/olp status
|
||||
/olp usage
|
||||
/olp models
|
||||
/olp help
|
||||
```
|
||||
|
||||
Output is wrapped in a monospace code block. Long responses are truncated
|
||||
to fit Telegram's ~4096-char per-message limit; a `... [truncated, use SSH
|
||||
for full]` suffix marks where the cut happened.
|
||||
|
||||
## Authorization model
|
||||
|
||||
The plugin sends `Authorization: Bearer <apiKey>` on every request. OLP's
|
||||
server enforces:
|
||||
|
||||
- **public-tier endpoints** (`/health`, `/v1/models`) accept any non-revoked
|
||||
key (or no key at all if `auth.allow_anonymous: true`).
|
||||
- **owner-tier endpoints** (`/v0/management/*`, `/cache/stats`) reject any
|
||||
non-owner key with 403.
|
||||
|
||||
If you see `401 unauthorized` or `403 forbidden` in chat:
|
||||
|
||||
- Verify the configured `apiKey` is a non-revoked **owner**-tier key.
|
||||
- Verify the key was created on the same host running the OLP server (keys
|
||||
are stored under `~/.olp/keys/` and validated by hash on the server side).
|
||||
- Check the OLP server's `/health` directly with `curl` to confirm
|
||||
reachability.
|
||||
|
||||
## Port resolution priority
|
||||
|
||||
1. `OLP_PROXY_URL` env (full URL) — useful when the gateway runs on a
|
||||
different host than OLP and you proxy in via Tailscale.
|
||||
2. `OLP_PORT` env (port only; localhost assumed).
|
||||
3. Plugin config `proxyUrl`.
|
||||
4. Fallback `http://127.0.0.1:4567`.
|
||||
|
||||
## Why no Telegram/Discord SDK dependency
|
||||
|
||||
OpenClaw provides the transport (Telegram bot + Discord bot are gateway
|
||||
features). This plugin only registers a slash command — it does not open
|
||||
its own websocket / long-poll connection. That means:
|
||||
|
||||
- No new npm dependency.
|
||||
- No bot tokens stored in plugin config.
|
||||
- Plugin works for any OpenClaw-supported chat surface (currently Telegram +
|
||||
Discord; future surfaces inherit automatically).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [Local `olp` CLI](../bin/olp.mjs) — the full mutating-capable surface.
|
||||
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor this is ported from.
|
||||
- [ADR 0010](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter.
|
||||
- [ADR 0007](../docs/adr/0007-multi-key-auth.md) — multi-key auth model that gates owner-tier subcommands.
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* OLP Plugin — registers /olp as a native slash command in the OpenClaw gateway.
|
||||
* Calls the local OLP proxy and formats the response for Telegram/Discord.
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
|
||||
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
|
||||
* to the OLP namespace with two structural differences:
|
||||
*
|
||||
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
|
||||
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
|
||||
* shared / persistent surfaces; rotating an owner key or pulling raw audit
|
||||
* logs from a chat client is a security regression. Use SSH + the local
|
||||
* `olp` CLI for those operations.
|
||||
*
|
||||
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
|
||||
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
|
||||
* OLP API key with owner_tier="owner". The plugin config carries that key;
|
||||
* operators are advised to mint a dedicated bot key (NOT the maintainer's
|
||||
* personal owner key) so revocation is scoped.
|
||||
*
|
||||
* Port resolution (in priority order):
|
||||
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
|
||||
* 2. OLP_PORT env (port only; localhost assumed)
|
||||
* 3. Plugin config `proxyUrl`
|
||||
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
|
||||
*
|
||||
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
|
||||
* mutating operations. Mapping table is in ./README.md.
|
||||
*/
|
||||
|
||||
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
|
||||
|
||||
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
|
||||
export function mono(text) {
|
||||
return "```\n" + text + "\n```";
|
||||
}
|
||||
|
||||
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
|
||||
export function bar(pct, width = 16) {
|
||||
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
|
||||
const filled = Math.round(p * width);
|
||||
return "█".repeat(filled) + "░".repeat(width - filled);
|
||||
}
|
||||
|
||||
/** Status icon — used in `/olp status` summary lines. */
|
||||
export function statusIcon(status) {
|
||||
if (status === "ok" || status === true) return "🟢";
|
||||
if (status === "degraded" || status === "warn") return "🟡";
|
||||
return "🔴";
|
||||
}
|
||||
|
||||
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
|
||||
export function truncateForChat(text, maxChars = 3900) {
|
||||
if (text.length <= maxChars) return text;
|
||||
const SUFFIX = "\n... [truncated, use SSH for full]";
|
||||
// Reserve room for the suffix so the final string is <= maxChars.
|
||||
const room = Math.max(0, maxChars - SUFFIX.length);
|
||||
return text.slice(0, room) + SUFFIX;
|
||||
}
|
||||
|
||||
// ── Proxy URL resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the proxy base URL. Order: OLP_PROXY_URL env → OLP_PORT env →
|
||||
* plugin config `proxyUrl` → default http://127.0.0.1:4567.
|
||||
*
|
||||
* Exported for test injection. Pass `env` to override `process.env` and
|
||||
* `config` to override the plugin config block.
|
||||
*/
|
||||
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
|
||||
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
|
||||
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
|
||||
if (config.proxyUrl) return config.proxyUrl;
|
||||
return "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
// ── HTTP helper ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
|
||||
*
|
||||
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
|
||||
*/
|
||||
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
|
||||
const headers = {};
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
const resp = await fetchFn(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Subcommand formatters ──────────────────────────────────────────────────
|
||||
//
|
||||
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
|
||||
// string. The dispatcher fetches + delegates. This split makes the formatters
|
||||
// unit-testable without an HTTP mock.
|
||||
|
||||
export function fmtStatus(body) {
|
||||
const icon = statusIcon(body.ok ? "ok" : "fail");
|
||||
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
|
||||
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
|
||||
if (body.providers?.status && typeof body.providers.status === "object") {
|
||||
for (const [name, s] of Object.entries(body.providers.status)) {
|
||||
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
|
||||
}
|
||||
}
|
||||
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
|
||||
const c = body.stats?.cache;
|
||||
if (c) {
|
||||
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
|
||||
}
|
||||
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||
out += `\nRecent errors (${body.recent_errors.length}):\n`;
|
||||
for (const e of body.recent_errors.slice(0, 3)) {
|
||||
const ts = (e.time || "").slice(11, 19);
|
||||
const msg = String(e.message ?? "").slice(0, 60);
|
||||
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtHealth(body) {
|
||||
const icon = statusIcon(body.ok ? "ok" : "fail");
|
||||
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
|
||||
if (body.uptime_human || body.uptimeHuman) {
|
||||
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
|
||||
}
|
||||
// D74 P2-4 fix: server.mjs /health full payload is
|
||||
// body.providers = { enabled: N, available: N, status: { <name>: {...} } }
|
||||
// The plugin previously iterated Object.entries(body.providers), which
|
||||
// surfaced `enabled`, `available`, and `status` as pseudo-providers
|
||||
// (typeof status === 'object' → loop body fired with name='status').
|
||||
// Walk providers.status when present; fall back to providers.* for the
|
||||
// older OCP shape that lacks the .status wrapper.
|
||||
if (body.providers && typeof body.providers === "object") {
|
||||
const enabled = body.providers.enabled;
|
||||
const available = body.providers.available;
|
||||
if (typeof enabled === "number" || typeof available === "number") {
|
||||
out += `Providers: ${enabled ?? "?"} enabled / ${available ?? "?"} available\n`;
|
||||
}
|
||||
const statusMap = body.providers.status && typeof body.providers.status === "object"
|
||||
? body.providers.status
|
||||
: body.providers;
|
||||
const entries = Object.entries(statusMap).filter(
|
||||
([name, s]) => typeof s === "object" && s !== null && name !== "enabled" && name !== "available" && name !== "status"
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
out += `\nProviders:\n`;
|
||||
for (const [name, s] of entries) {
|
||||
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||
const spawn = typeof s?.activeSpawns === "number" ? ` spawns=${s.activeSpawns}` : "";
|
||||
out += ` ${i} ${name}${spawn}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtUsage(body) {
|
||||
let out = "OLP usage (24h)\n";
|
||||
out += "─────────────────────────────\n";
|
||||
const w = body.window_24h ?? body.usage_24h ?? {};
|
||||
if (w.requests !== undefined) {
|
||||
out += `Requests: ${w.requests}\n`;
|
||||
out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`;
|
||||
out += `Fallbacks: ${w.fallbacks ?? "?"}\n`;
|
||||
} else if (typeof body.cache_hit_24h === "number") {
|
||||
// Dashboard-data shape: cache_hit_24h is a rate ∈ [0,1]
|
||||
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
|
||||
}
|
||||
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
out += `\nPer-provider quota:\n`;
|
||||
for (const q of body.quota) {
|
||||
const pct = typeof q.percent_used === "number" ? q.percent_used : null;
|
||||
const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api";
|
||||
out += ` ${String(q.name ?? "?").padEnd(10)}${bar0}\n`;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
|
||||
out += `\nTop fallback chains (24h):\n`;
|
||||
for (const f of body.top_fallback_chains_24h.slice(0, 5)) {
|
||||
out += ` ${String(f.count ?? "?").padStart(5)} ${(f.chain ?? []).join(" → ")}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtModels(body) {
|
||||
const data = body.data ?? [];
|
||||
if (data.length === 0) return "No models.";
|
||||
let out = `Models (${data.length})\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const m of data) {
|
||||
out += ` ${m.id}${m.owned_by ? ` (${m.owned_by})` : ""}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtCache(body) {
|
||||
let out = "OLP cache\n";
|
||||
out += "─────────────────────────────\n";
|
||||
out += `Entries: ${body.size ?? body.entries ?? "?"}\n`;
|
||||
out += `Hits: ${body.hits ?? 0}\n`;
|
||||
out += `Misses: ${body.misses ?? 0}\n`;
|
||||
out += `Inflight: ${body.inflightCount ?? 0}\n`;
|
||||
if (typeof body.evictions === "number") {
|
||||
out += `Evictions: ${body.evictions}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtProviders(registry, configEnabled) {
|
||||
const providers = registry?.providers ?? {};
|
||||
const names = Object.keys(providers);
|
||||
let out = `OLP providers (${names.length} in registry)\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const name of names) {
|
||||
const p = providers[name];
|
||||
const enabled = configEnabled?.[name] === true ? "enabled " : "disabled";
|
||||
const tier = p?.tier ?? "?";
|
||||
const modelCount = (p?.models ?? []).length;
|
||||
const candidate = p?.candidate === true ? " (candidate)" : "";
|
||||
out += ` ${name.padEnd(10)} ${enabled} tier ${tier} models ${String(modelCount).padStart(2)}${candidate}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtChainShow(chains, target) {
|
||||
if (!chains || Object.keys(chains).length === 0) {
|
||||
return "No chains configured.";
|
||||
}
|
||||
if (target) {
|
||||
const chain = chains[target];
|
||||
if (!chain) {
|
||||
return `Model "${target}" not in routing.chains.\nConfigured: ${Object.keys(chains).join(", ")}`;
|
||||
}
|
||||
let out = `${target}:\n`;
|
||||
for (const hop of chain) {
|
||||
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let out = "OLP routing.chains\n";
|
||||
out += "─────────────────────────────\n";
|
||||
for (const [model, chain] of Object.entries(chains)) {
|
||||
out += `${model}:\n`;
|
||||
for (const hop of chain) {
|
||||
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtDoctor(body) {
|
||||
// body shape: { checks, fail_count, warn_count, ok_count, kind, summary, next_action }
|
||||
let out = `OLP doctor — ${body.summary ?? "?"}\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const c of (body.checks ?? []).slice(0, 20)) {
|
||||
const icon = c.status === "ok" ? "🟢" : c.status === "warn" ? "🟡" : "🔴";
|
||||
out += ` ${icon} ${String(c.id ?? "?").padEnd(34)} ${String(c.message ?? "").slice(0, 60)}\n`;
|
||||
}
|
||||
if ((body.checks ?? []).length > 20) {
|
||||
out += ` ... (${body.checks.length - 20} more — use SSH 'olp doctor' for full output)\n`;
|
||||
}
|
||||
out += `\nfail=${body.fail_count ?? 0} warn=${body.warn_count ?? 0} ok=${body.ok_count ?? 0} kind=${body.kind ?? "?"}\n`;
|
||||
if (body.next_action?.ai_executable?.length > 0) {
|
||||
out += `\nNext (AI-executable):\n`;
|
||||
for (const cmd of body.next_action.ai_executable.slice(0, 5)) {
|
||||
out += ` $ ${cmd}\n`;
|
||||
}
|
||||
}
|
||||
if (body.next_action?.human_required?.length > 0) {
|
||||
out += `\nNext (human-required):\n`;
|
||||
for (const step of body.next_action.human_required.slice(0, 5)) {
|
||||
out += ` • ${step}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Help text ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function cmdHelp() {
|
||||
return `OLP Commands (read-only)
|
||||
─────────────────────────────
|
||||
/olp status Process + provider + cache snapshot
|
||||
/olp health /health endpoint (public-ok)
|
||||
/olp usage 24h request stats + per-provider quota
|
||||
/olp models Available models
|
||||
/olp cache Cache stats
|
||||
/olp providers Provider registry + enabled flags
|
||||
/olp chain show [model] Routing chain(s) from server config
|
||||
/olp doctor Diagnostic checks + suggested next action
|
||||
/olp help This message
|
||||
|
||||
Mutating commands (keygen / revoke / restart / logs) are NOT
|
||||
available from chat by design — use SSH + the local 'olp' CLI.`;
|
||||
}
|
||||
|
||||
// ── Dispatcher ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure subcommand dispatcher. Returns `{ text }` always — caller wraps in
|
||||
* mono() for the chat surface.
|
||||
*
|
||||
* Exported for unit tests. Injects:
|
||||
* - fetchFn (default global fetch)
|
||||
* - proxyUrl (resolved upstream so tests can pin)
|
||||
* - apiKey (from plugin config)
|
||||
* - registry (models-registry.json — caller provides since this module
|
||||
* ships in `olp-plugin/` and the file is a sibling concept living at
|
||||
* the repo root)
|
||||
* - chainsLocal (local routing.chains override — usually empty; the
|
||||
* server-side /v0/management/status already exposes provider+chain
|
||||
* state, but chain-show is the one local-config touch that mirrors
|
||||
* `olp chain show`)
|
||||
*/
|
||||
export async function dispatch(rawArgs, opts) {
|
||||
const {
|
||||
proxyUrl,
|
||||
apiKey,
|
||||
registry,
|
||||
chainsLocal = {},
|
||||
fetchFn = fetch,
|
||||
} = opts;
|
||||
|
||||
const raw = (rawArgs || "").trim();
|
||||
const spaceIdx = raw.indexOf(" ");
|
||||
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
|
||||
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
|
||||
|
||||
try {
|
||||
switch (subcmd) {
|
||||
case "status": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v0/management/status`, { apiKey, fetchFn });
|
||||
return { text: fmtStatus(body) };
|
||||
}
|
||||
case "health": {
|
||||
const body = await fetchJSON(`${proxyUrl}/health`, { apiKey, fetchFn });
|
||||
return { text: fmtHealth(body) };
|
||||
}
|
||||
case "usage": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v0/management/dashboard-data`, { apiKey, fetchFn });
|
||||
return { text: fmtUsage(body) };
|
||||
}
|
||||
case "models": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v1/models`, { apiKey, fetchFn });
|
||||
return { text: fmtModels(body) };
|
||||
}
|
||||
case "cache": {
|
||||
const body = await fetchJSON(`${proxyUrl}/cache/stats`, { apiKey, fetchFn });
|
||||
return { text: fmtCache(body) };
|
||||
}
|
||||
case "providers": {
|
||||
// models-registry.json + (optionally) the server's idea of which are
|
||||
// enabled. /v0/management/status carries that and is owner-gated, but
|
||||
// /v1/models lists what's exposed publicly. For the chat surface we
|
||||
// use the public registry shape — config.enabled is a local-config
|
||||
// concept and the plugin doesn't have filesystem access to
|
||||
// ~/.olp/config.json by design.
|
||||
return { text: fmtProviders(registry, {}) };
|
||||
}
|
||||
case "chain": {
|
||||
// /olp chain show [model]
|
||||
const inner = subargs.trim();
|
||||
const parts = inner.split(/\s+/).filter(Boolean);
|
||||
if (parts[0] !== "show") {
|
||||
return { text: `Usage: /olp chain show [model]` };
|
||||
}
|
||||
const target = parts[1] ?? null;
|
||||
return { text: fmtChainShow(chainsLocal, target) };
|
||||
}
|
||||
case "doctor": {
|
||||
// /v0/management/doctor doesn't exist yet — D67 added doctor as a CLI
|
||||
// surface only. The plugin reports that explicitly so families know
|
||||
// to use SSH + `olp doctor` rather than waiting for a chat response.
|
||||
return {
|
||||
text: `/olp doctor is not yet wired through HTTP (planned for Phase 5+).\n` +
|
||||
`Run \`olp doctor\` over SSH on the host running the OLP server\n` +
|
||||
`for the full diagnostic output.`,
|
||||
};
|
||||
}
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "":
|
||||
return { text: cmdHelp() };
|
||||
default:
|
||||
return { text: `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return { text: `OLP error: ${err.message ?? String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin entry point (consumed by OpenClaw gateway) ──────────────────────
|
||||
|
||||
/**
|
||||
* OpenClaw plugin entry. The gateway calls this with its `api` registration
|
||||
* object; we register the `/olp` slash command and a handler that resolves
|
||||
* the proxy URL + API key from plugin config + env, then delegates to
|
||||
* `dispatch()`.
|
||||
*
|
||||
* The `registry` (models-registry.json) is read lazily inside the handler
|
||||
* so that a stale plugin install doesn't bind to an old snapshot — and so
|
||||
* the plugin module stays import-time-pure for tests.
|
||||
*/
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "olp",
|
||||
description: "OLP — usage, health, status, doctor, etc. (read-only)",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const cfg = ctx.config ?? {};
|
||||
const apiKey = cfg.apiKey ?? process.env.OLP_API_KEY ?? null;
|
||||
const proxyUrl = resolveProxyUrl({ env: process.env, config: cfg });
|
||||
// Lazy load to avoid binding the import to the OpenClaw gateway's
|
||||
// ESM cache (which may pre-resolve at plugin-discovery time).
|
||||
let registry;
|
||||
try {
|
||||
// Convert file path to URL for ESM `import(...)`.
|
||||
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
||||
const { dirname, resolve: pathResolve } = await import("node:path");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const registryUrl = pathToFileURL(pathResolve(here, "..", "models-registry.json")).href;
|
||||
registry = (await import(registryUrl, { with: { type: "json" } })).default;
|
||||
} catch (e) {
|
||||
registry = { providers: {} };
|
||||
}
|
||||
// Local chains config is not currently surfaced through HTTP. For
|
||||
// chat-side chain-show we fall back to an empty map; operators
|
||||
// wanting the live config view should use `olp chain show` over SSH.
|
||||
const chainsLocal = {};
|
||||
const { text } = await dispatch(ctx.args ?? "", {
|
||||
proxyUrl,
|
||||
apiKey,
|
||||
registry,
|
||||
chainsLocal,
|
||||
});
|
||||
return { text: mono(truncateForChat(text)) };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "olp",
|
||||
"name": "OLP Commands",
|
||||
"description": "Slash commands for OLP — /olp status, /olp usage, /olp health, etc. (read-only by design; mutations require SSH).",
|
||||
"version": "0.4.0",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"proxyUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:4567",
|
||||
"description": "Full URL of the OLP proxy. Default matches D60 OLP_PORT=4567. Overridable via OLP_PROXY_URL or OLP_PORT env."
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Owner-tier OLP API key (olp_xxx). Required for owner-only subcommands (status / usage / cache). Use a dedicated bot key — DO NOT share the maintainer's personal owner key."
|
||||
}
|
||||
},
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "olp-plugin",
|
||||
"version": "0.4.0",
|
||||
"description": "OpenClaw gateway plugin — /olp slash commands for the OLP proxy (read-only)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"keywords": ["openclaw", "plugin", "olp", "proxy"],
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"openclaw": {
|
||||
"type": "plugin",
|
||||
"id": "olp",
|
||||
"pluginManifest": "openclaw.plugin.json"
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -1,17 +1,36 @@
|
||||
{
|
||||
"name": "olp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
"bin": {
|
||||
"olp-keys": "./bin/olp-keys.mjs"
|
||||
"olp": "./bin/olp.mjs",
|
||||
"olp-keys": "./bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs",
|
||||
"olp-connect": "./bin/olp-connect"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node test-features.mjs",
|
||||
"olp-keys": "node bin/olp-keys.mjs"
|
||||
"olp": "node bin/olp.mjs",
|
||||
"olp-keys": "node bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs",
|
||||
"olp-connect": "bash bin/olp-connect"
|
||||
},
|
||||
"files": [
|
||||
"server.mjs",
|
||||
"bin/",
|
||||
"lib/",
|
||||
"olp-plugin/",
|
||||
"models-registry.json",
|
||||
"dashboard.html",
|
||||
"README.md",
|
||||
"ALIGNMENT.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"docs/"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
+912
-193
File diff suppressed because it is too large
Load Diff
+4088
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user