mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704d4fc8a0 | ||
|
|
6605b7b14a | ||
|
|
6edf6e0b94 | ||
|
|
f3716a19fd | ||
|
|
ee4d9459aa | ||
|
|
53afea47ca | ||
|
|
0bdecd1235 | ||
|
|
e69e908dae | ||
|
|
e6701ff698 | ||
|
|
0048481764 | ||
|
|
ba69a3c13b | ||
|
|
679e3b367d | ||
|
|
9b66326e72 | ||
|
|
1062e88e77 |
+190
-1
@@ -4,7 +4,196 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
## Unreleased
|
||||
|
||||
(empty — Phase 4 entries land here once Phase 4 opens)
|
||||
(empty — Phase 5 entries land here once Phase 5 opens)
|
||||
|
||||
## v0.4.4 — 2026-05-26
|
||||
|
||||
### D78 — `bin/olp-connect` stale-strings cleanup + README CDN-safe URL + repo-visibility flip
|
||||
|
||||
Patch release on top of v0.4.3. Three small issues caught when running `olp-connect` for real on MacBook (D77 client-install verification):
|
||||
|
||||
- **G11 fix (repo visibility).** Repo `dtzp555-max/olp` flipped from PRIVATE → PUBLIC during this session, closing the original G11 finding (`bash <(curl -fsSL .../main/bin/olp-connect)` returned 404 because anonymous curl can't fetch from private repos). README's `/main/` URL works going forward; GitHub's raw CDN may serve a stale 404 for `/main/` for ~5-15min after the visibility flip due to negative caching. D78 defends against this by adding a **tag-pinned URL (`/v0.4.4/bin/olp-connect`) as the primary recommendation in README**, with `/main/` listed as an alternative for trusted-head users. Tag-pinned URLs bypass the negative-cache because the tag ref was never queried while the repo was private.
|
||||
- **G12 fix (`detect_openclaw` claimed plugin not shipped).** `bin/olp-connect`'s OpenClaw detection block said `"The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED"` — but D71-D73 shipped `olp-plugin/` at v0.4.0. D78 replaces the stale text with real install instructions: `git clone` + `openclaw plugins install ./olp-plugin/` (or symlink), edit `~/.openclaw/openclaw.json` with a dedicated bot apiKey, restart gateway. Points at `docs/integrations/openclaw.md` for the full setup.
|
||||
- **G13 fix (`olp-connect` self-version hardcoded literal).** Pre-D78 the script declared `OLP_CONNECT_VERSION="0.4.0-phase4"` as a hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3 (the maintain-the-literal-per-release pattern is reliably forgotten). D78 derives the version at runtime from the sibling `package.json` via python3 — when the script is invoked from a checked-out repo, version resolves to the actual `package.json` value; when invoked via `curl … | bash` with no on-disk package.json next to it, falls back to `unknown`. Now `bash bin/olp-connect --version` prints `olp-connect 0.4.4` automatically with no manual touch needed at the next release.
|
||||
|
||||
**Pre-publish audit.** Per `~/.cc-rules/docs/guides/pre-publish-audit.md` checklist (2026-05-26 session, before the visibility flip):
|
||||
- Identity scrub: 0 hits (no personal names / hostnames / home paths / personal emails leaked into the working tree)
|
||||
- Credential scrub: 0 real tokens — all `olp_` matches are placeholder (`olp_XXXX...`) or test fixtures (`olp_not-a-real-key-...`); gitleaks: "no leaks found"
|
||||
- Git-history author emails: 78 commits, two emails (`dtzp555@gmail.com` local + `taodeng1977@gmail.com` GitHub-account squash-merges). Maintainer chose Option A (accept) — the GitHub-account email was already verified-public on the maintainer's GitHub profile, so the visibility flip exposes nothing new.
|
||||
|
||||
**Test count:** 717 (v0.4.3) → 720 (v0.4.4). +3 D78 regression tests in Suite 36:
|
||||
- 36v — pins absence of `NOT YET SHIPPED` text + presence of real install path
|
||||
- 36w — pins runtime version derivation from package.json (hardcoded literal gone)
|
||||
- 36x — pins README's tag-pinned-URL recommendation
|
||||
|
||||
**Authority:** D77 MacBook client-install verification session (2026-05-26); `~/.cc-rules/docs/guides/pre-publish-audit.md`. Process learning: every README that includes a `curl <raw-URL> | bash` install pattern should pin to a release tag (not `/main/`) for CDN-cache resilience. The /main/ form is correct for the long-tail (when no negative cache exists) but the tag-pinned form survives the visibility-flip transient + survives any future force-push to main.
|
||||
|
||||
**Out of D78 scope:**
|
||||
- F6 (doctor client-side vs server-side check separation) — Phase 5 ADR amendment.
|
||||
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive `typeof hopModel === 'string'` invariant) — both genuine follow-ups, neither blocking.
|
||||
- `scripts/migrate-from-ocp.mjs` — Phase 7.
|
||||
|
||||
## v0.4.3 — 2026-05-26
|
||||
|
||||
### D76 — README install-path overhaul + `OLP_BIND` env + AI-driven install prompt + ADR 0011 amendment
|
||||
|
||||
Patch release closing the install-experience gap. v0.4.0–v0.4.2 README's Quick Start was placeholder text with fictional commands (`npm install -g @dtzp555-max/olp` — package isn't published; `olp setup` / `olp start` — don't exist). 10 real gaps catalogued + fixed in one D-day; `OLP_BIND` env wired so the documented LAN onboarding flow actually works; AI-driven install prompt added per the Phase 4 charter brainstorm's #2 OCP inheritance candidate (was deferred at D64-D67 to the doctor framework only; D76 closes the README half).
|
||||
|
||||
- **G1-G7 (README "Quick Start" was fictional)** — rewrote § "Manual install" with the real sequence: prerequisites (Node ≥ 18 + provider CLI install matrix) → `git clone` → `npm test` verify → `olp-keys keygen --owner` first → provider OAuth (claude/codex/mistral per-CLI flows) → write `~/.olp/config.json` with the minimum that actually serves traffic → `npm start` → smoke-test → IDE pointing. Each step empirically verified against the PI231 + Mac mini E2E session (2026-05-26).
|
||||
- **G8 (LAN unreachable — F5)** — added `OLP_BIND` env (default `127.0.0.1`). Operators set `OLP_BIND=0.0.0.0` (or a specific LAN IP) to accept LAN connections so `olp-connect <ip>` can actually reach the server. Pre-D76 the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`, making the documented LAN-onboarding flow only usable through an SSH tunnel. ADR 0011's original wording referenced a `BIND_ADDRESS` concept that didn't exist; D76 makes it operational.
|
||||
- **G10 (no AI-install pattern)** — README § "Install with your AI (the fast path)" added. Verbatim prompt that the operator pastes into Claude Code / Cursor / Copilot / Aider; the AI follows the README + uses `olp doctor --json` machine-readable `next_action.ai_executable[]` (D64-D67) for self-repair, stopping only when `human_required[]` is non-empty (the provider OAuth dances). This closes the Phase 4 brainstorm Top-5 inheritance candidate #2 — the OCP "paste this prompt" pattern that D64-D67 only half-built.
|
||||
- **Opening compressed** — § "Why OLP" (3 paragraphs of OCP billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + § "Install with your AI" / § "Manual install" without needing to digest 2026-05-14 / 2026-06-15 Anthropic billing history first. OCP users get a one-line pointer at the top.
|
||||
- **§ "Configuration" full schema documentation** — replaced the placeholder with the actual `~/.olp/config.json` schema including every field that v0.4.x reads. Cross-references ADR 0004/0007/0010/0011.
|
||||
- **§ "Environment Variables" extended** — added `OLP_BIND`, `OLP_API_KEY`, `OLP_OWNER_TOKEN`, `OLP_PROXY_URL` rows that were used throughout the manual-install flow but undocumented.
|
||||
|
||||
**ADR 0011 § "Deployment configurations" amendment.** Codifies the three deployment trust contexts (`127.0.0.1` loopback / RFC1918 + tailnet LAN / `0.0.0.0` public — with `advertise_anonymous_key: true` only safe in the first two). Documents the new `anonymous_key_advertised_with_lan_bind` startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent `BIND_ADDRESS`.
|
||||
|
||||
**Test count:** 714 (v0.4.2) → 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning `OLP_BIND` wiring + safety warn + ADR amendment.
|
||||
|
||||
**Out of D76 scope (deferred):**
|
||||
- F6 (doctor client-side vs server-side check separation) — needs design ADR for a `--remote` mode. Phase 5.
|
||||
- D75 reviewer P2-1 (ADR 0004 amendment for per-hop schema) + P2-2 (defensive `typeof hopModel === 'string'`) — both genuine follow-ups, neither blocking.
|
||||
- `scripts/migrate-from-ocp.mjs` — Phase 7.
|
||||
|
||||
**Authority:** PI231 + Mac mini E2E session (2026-05-26, post-v0.4.2 verification revealed the 10 README gaps); ADR 0011 amendment self-cites; Phase 4 charter (ADR 0010) Top-5 inheritance candidate #2 (AI-driven self-repair). Process learning: every D-day reviewer rubric should add "open README in §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0.
|
||||
|
||||
## v0.4.2 — 2026-05-26
|
||||
|
||||
### Post-v0.4.1 hotfix batch (D75) — real-machine E2E findings
|
||||
|
||||
Patch release fixing 5 bugs caught by **real-machine E2E testing on PI231 + Mac mini (2026-05-26 session)** — bugs that prior D-day reviewers AND the post-v0.4.0 maintainer review both missed because they reviewed against spec text and against the local OLP install's `~/.codex/auth.json` shape (cached from an older codex CLI version), not against real provider CLIs running on a remote operator host that did `npm install -g @openai/codex` for the first time on 2026-05-26 and got codex CLI v0.133.0.
|
||||
|
||||
**Root cause of the missed-bug class.** D6 (codex plugin authoring) explicitly documented three unpinned assumptions (A3 = access-token field name, A4 = NDJSON event schema, A2-adjacent = trusted-directory sandbox). D6 noted "D7 E2E will pin." D7 then shipped without performing real-codex-CLI E2E (the E2E gating mark was carried but the actual run was deferred). Every subsequent D-day reviewer trusted the D6/D7 codex plugin code unchanged because the static review couldn't see that the v0.133.0 CLI had moved the auth-token field, the event schema, AND added a new trusted-directory sandbox flag. The D74 maintainer review focused on `/health` / `/cache/stats` / `/v0/management/dashboard-data` payload shapes — none of which exercise the codex plugin's spawn path. F7 (per-hop model override) is a different class of miss — every reviewer read `executeHopFn(provider, model, ir)` and saw `model` consumed for cache key + audit ctx, but none traced through to confirm `model` is ALSO substituted into the IR passed to `provider.spawn()`. The function signature implied per-hop semantics that the body never fully delivered.
|
||||
|
||||
- **[F1] codex auth.json schema pin — codex CLI v0.133.0 nests the access token under `tokens.access_token`** (verified empirically on PI231 / Mac mini, 2026-05-26). Pre-D75 `readAuthArtifact()` read only top-level `creds.access_token` / `creds.token` / `creds.accessToken` — all undefined under v0.133.0 → returned `null` → OLP reported "auth artifact missing" via `/health` and `olp doctor` AND refused to spawn codex even when the user had fully completed `codex login`. Fix: prepend `creds?.tokens?.access_token` to the precedence chain at BOTH call sites (`OPENAI_CODEX_AUTH_PATH` override branch + default `$CODEX_HOME/auth.json` branch). Legacy top-level fields preserved as fallback for backward compat with older codex CLI versions.
|
||||
- **[F2] codex spawn args — codex CLI v0.133.0 trusted-directory sandbox requires `--skip-git-repo-check`.** v0.133.0 refuses with `"Not inside a trusted directory and --skip-git-repo-check was not specified."` when spawned outside a git repo, exits non-zero with zero NDJSON output → OLP surfaces `SPAWN_FAILED` with no usable chunks → the fallback engine advances to next hop unnecessarily even when codex is configured and authenticated. OLP's typical deploy CWD (`~/olp/`) is NOT a git repo on operator hosts. Fix: add `'--skip-git-repo-check'` to the args array before `--model`. OLP is the trusted caller (operator's own server invoking the operator's own subscription via documented `codex exec` automation); the sandbox safeguards interactive shells, not pre-authorized automation.
|
||||
- **[F3] codex NDJSON event shape pin — codex CLI v0.133.0 emits `item.completed` + `turn.completed` + `turn.failed`**, not the D6-assumed `content`/`delta`/`text` + `type:'stop'`/`done:true` shapes. Real v0.133.0 stream (verified empirically): `{"type":"thread.started",...}` → `{"type":"turn.started"}` → `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}` → `{"type":"turn.completed","usage":{...}}`. Pre-D75, every chunk was silently dropped by `codexChunkToIR()` → response body had `content: null`. Fix: prepend three new recognizers (`item.completed` with `item.type === 'agent_message'` → IR delta; `turn.completed` → IR stop; `turn.failed` → IR error). Legacy D6 defensive recognizers preserved below as forward/backward compat fallbacks.
|
||||
- **[F4] `olp status` reads `body.stats.cache.size` (not OCP-era `body.cache.entries`).** Same class as D74 P2-3 (which fixed `cmdUsage` + `cmdCache`); D74 missed the parallel bug in `cmdStatus`. Server payload nests cache stats as `body.stats.cache.{hits, misses, size, inflightCount}` per `server.mjs handleManagementStatus`, and `CacheStore.stats()` has no `entries` field per `lib/cache/store.mjs`. Pre-D75 output showed `entries=?`. Fix: read `c.size` for entries display; also surface `inflightCount` when present.
|
||||
- **[F7] per-hop chain `model` field now overrides IR model in `provider.spawn()`.** Pre-D75 `executeHopFn(hopProvider, hopModel, irReq)` used `hopModel` for cache key + audit ctx but passed the ORIGINAL `irReq` (with `irReq.model` = user's original request) to `hopProviderPlugin.spawn(irReq, authContext)`. A chain config `[{provider:anthropic, model:claude-X}, {provider:openai, model:gpt-5.5}]` would always spawn BOTH plugins with `--model claude-X` — openai rejected the unknown model and the chain died. This broke the core OLP value prop (cross-provider fallback with provider-appropriate model substitution per hop). Fix: build a per-hop IR variant with `{...irReq, model: hopModel}` and pass that to spawn. Conditional skips clone when `hopModel === irReq.model` (common case: single-provider chains, or single-hop chains where the chain config repeats the request model). Applied to BOTH the buffered path (`executeHopFn`) AND the streaming path (`sourceFactory` for `getOrComputeStreaming`). **Authority:** ADR 0004 § Chain advancement step 1 (per-hop config supplies provider AND model — the contract was always specified, but the code didn't complete it).
|
||||
|
||||
**Phase 5 process learning recorded.** Every provider plugin's D-day must include a real-CLI E2E ON A REMOTE OPERATOR HOST before merging — not on the maintainer workstation (which may have an older CLI cached from a prior install, hiding new field renames / new sandbox flags / new event shapes). The D6/D7 codex E2E was deferred and that deferral compounded across 3 layers (D6 = unpinned, D7 = pinning deferred, D8+ = trusted D6/D7 unchanged). F7 reinforces a separate lesson: when a function signature takes `(provider, model, ir)`, reviewers must check that `model` is consumed everywhere downstream, not just at the call site they happened to look at.
|
||||
|
||||
**Out of D75 scope (deferred to Phase 5 explicit ADR amendments):**
|
||||
- F5 (server bind 127.0.0.1 / `OLP_BIND` env) — needs `lib/keys.mjs` anonymous-key trust boundary review before binding to non-loopback by default
|
||||
- F6 (`olp doctor` client-vs-server-side limit detection) — needs design ADR amendment for trigger taxonomy
|
||||
|
||||
- **Test count delta:** 704 (v0.4.1) → 714 (v0.4.2). +10 D75 regression tests in Suite 36 (36i through 36r).
|
||||
- **Files touched:** `lib/providers/codex.mjs` (F1+F2+F3), `bin/olp.mjs` (F4 cmdStatus), `server.mjs` (F7 buffered + streaming spawn sites), `test-features.mjs` (Suite 36 extension), `package.json` (version), `CHANGELOG.md` (this entry).
|
||||
- **Authority:** ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback engine — per-hop model contract), `lib/providers/codex.mjs` D6 assumption A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex CLI v0.133.0 on-disk schema + `codex exec --help` output verified empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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 4
|
||||
current_pre_release_identifier: "0.4.0-phase4"
|
||||
current_phase: Phase 5
|
||||
current_pre_release_identifier: "0.5.0-phase5"
|
||||
phase_close_trigger: explicit maintainer action (not automated)
|
||||
```
|
||||
|
||||
@@ -1,42 +1,209 @@
|
||||
# OLP — Open LLM Proxy
|
||||
|
||||
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
|
||||
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing + fallback + content-addressed caching. Your IDEs and family clients keep working as long as **any** of your subscriptions has quota left.
|
||||
|
||||
> **Status:** v0.3.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI (v0.2.0) + Phase 3 Dashboard + audit query layer + daily audit rotation (v0.3.0). Phase 4 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
||||
> **Status:** v0.4.3 shipped, 714+ tests. Phase 4 (Operator + Client UX) closed; Phase 5 scope is open. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
|
||||
|
||||
---
|
||||
|
||||
## Why OLP
|
||||
## What you get
|
||||
|
||||
On 2026-05-14, Anthropic announced (effective 2026-06-15) that `claude -p`, the Agent SDK, and third-party agent traffic move out of the Pro/Max subscription pool into a separate fixed monthly Agent SDK Credit pool. [OCP](https://github.com/dtzp555-max/ocp), OLP's predecessor, was a proxy around a single CLI — its core assumption was *"subscription = unlimited within rate limits"*. That assumption breaks for Anthropic on the effective date.
|
||||
|
||||
The structural response is to stop relying on one provider's subscription terms remaining favourable. OLP spreads risk across multiple providers whose subscriptions still include CLI/programmatic use, routes intelligently between them, and caches aggressively so every request that does spawn a CLI counts.
|
||||
|
||||
OLP is **not**: a commercial multi-tenant SaaS; an enterprise gateway competing with LiteLLM / OpenCode / CLIProxyAPI on breadth; a model-capability router ("route to the smartest model" — you pick the model); a conversation-state store (your client handles that).
|
||||
|
||||
See [`ALIGNMENT.md`](./ALIGNMENT.md) for OLP's constitution and [`docs/adr/`](./docs/adr/) for the founding ADRs.
|
||||
- **OpenAI-compatible** `/v1/chat/completions` endpoint — any IDE that speaks OpenAI (Cline / Continue.dev / Cursor / Aider) plugs in
|
||||
- **Multi-provider chain** — primary fails / quota dies → automatically falls back to the next provider (anthropic ↔ codex ↔ mistral by default; risk-tier framework guards which ones get enabled)
|
||||
- **Content-addressed cache** — repeat requests don't re-spawn the CLI; streaming requests dedup via singleflight tee
|
||||
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
|
||||
- **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
|
||||
- **AI-driven self-repair** — `olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
|
||||
- **Observability** — owner-only `/dashboard` (quota / 24h stats / 30d spend trend / top fallback chains)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Install with your AI (the fast path)
|
||||
|
||||
_placeholder — lands with Phase 1._
|
||||
If the manual steps feel like a lot, paste this verbatim into your AI coding assistant (Claude Code / Cursor / Copilot / Aider). It walks you through everything:
|
||||
|
||||
Anticipated shape:
|
||||
```
|
||||
I want to install OLP (Open LLM Proxy) on this machine. The repo is at
|
||||
https://github.com/dtzp555-max/olp and ships these tools that you can use:
|
||||
|
||||
1. `git clone` the repo to ~/olp
|
||||
2. README.md § "Manual install" has the sequenced steps. Follow them in order.
|
||||
3. After EACH step, run `node ~/olp/bin/olp.mjs doctor --json` (NOT `npx olp`
|
||||
— the package isn't on npm). Parse the JSON. The `kind` field tells you
|
||||
the state: noop / fresh_install / fix_server / fix_oauth / fix_provider /
|
||||
fix_config.
|
||||
4. If `kind != noop`, run the commands listed in `next_action.ai_executable[]`
|
||||
verbatim. Then re-run doctor to verify.
|
||||
5. STOP and ask me only when `next_action.human_required[]` is non-empty.
|
||||
That's where I need to do a browser OAuth flow you can't do for me.
|
||||
|
||||
The provider CLIs OLP spawns (claude / codex / vibe) need their own one-time
|
||||
OAuth — those are the only steps I personally have to do (Claude.ai login,
|
||||
ChatGPT login, Mistral API key). Everything else (clone, npm install of the
|
||||
provider CLIs, owner-key generation, config.json bootstrap, server start) is
|
||||
in your `ai_executable[]` and you should run it without asking.
|
||||
|
||||
Begin.
|
||||
```
|
||||
|
||||
Then sit back and respond when it asks for OAuth confirmation. This pattern works because `olp doctor` is purpose-built for AI consumption — every failure mode has a shell-executable repair command AND a human-required step listed separately.
|
||||
|
||||
---
|
||||
|
||||
## Manual install (5-10 min)
|
||||
|
||||
### 0. Prerequisites
|
||||
|
||||
- **Node.js ≥ 18.** Verify: `node --version`
|
||||
- **The provider CLIs you want OLP to spawn.** Install whichever you'll actually use:
|
||||
|
||||
| Provider | Install | Subscription |
|
||||
|---|---|---|
|
||||
| `anthropic` (`claude -p`) | `npm install -g @anthropic-ai/claude-code` | Claude Pro/Max (OAuth) |
|
||||
| `openai` (`codex exec`) | `npm install -g @openai/codex` | ChatGPT Plus/Pro (OAuth) or OpenAI API key |
|
||||
| `mistral` (`vibe --prompt`) | follow the `vibe` install docs | Le Chat Pro API key |
|
||||
|
||||
You only need to install the ones you'll route to. Single-provider OLP works fine.
|
||||
|
||||
### 1. Clone and verify the test suite
|
||||
|
||||
```bash
|
||||
# install
|
||||
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)
|
||||
olp start
|
||||
|
||||
# point your IDE at http://localhost:3456/v1/chat/completions with the OLP API key from `olp keys list`.
|
||||
git clone https://github.com/dtzp555-max/olp.git ~/olp
|
||||
cd ~/olp
|
||||
npm test # 714+ tests, ~5s, no external deps
|
||||
```
|
||||
|
||||
(If `npm test` fails here, stop — that means your Node version or the repo state is broken. Don't proceed to step 2.)
|
||||
|
||||
### 2. Bootstrap the owner key
|
||||
|
||||
The owner key is what you (and `olp-connect`) use to authenticate to OLP. Default config has `auth.allow_anonymous: false`, so you need a key BEFORE the server starts accepting requests.
|
||||
|
||||
```bash
|
||||
node ~/olp/bin/olp-keys.mjs keygen --owner --name=$(whoami)-laptop
|
||||
# Prints the plaintext token ONCE. Copy it now — you can't recover it later.
|
||||
# Example: olp_l23-PN46tDljmPATV94-KfOgOBO0Ed8theVjTdAgQoY
|
||||
```
|
||||
|
||||
Export it so the CLI subcommands can use it:
|
||||
|
||||
```bash
|
||||
export OLP_API_KEY=olp_l23-PN46... # paste your real token
|
||||
```
|
||||
|
||||
(Add to `~/.bashrc` / `~/.zshrc` to persist.)
|
||||
|
||||
### 3. Authenticate the providers (one-time OAuth)
|
||||
|
||||
Run each provider's own login flow. OLP's anthropic / openai / mistral plugins spawn these CLIs and reuse their cached credentials — OLP itself never touches the OAuth dance.
|
||||
|
||||
```bash
|
||||
# Anthropic (Claude Pro/Max subscription)
|
||||
claude setup-token
|
||||
# Opens a TUI / prints a URL. Authorize in browser. Paste the returned code.
|
||||
# Result: ~/.claude/.credentials.json
|
||||
|
||||
# OpenAI (ChatGPT subscription)
|
||||
codex login --device-auth
|
||||
# Prints a https://auth.openai.com/codex/device URL + 10-char code.
|
||||
# Open URL in browser, enter code, authorize.
|
||||
# Result: ~/.codex/auth.json
|
||||
|
||||
# Mistral (Le Chat API key)
|
||||
export MISTRAL_API_KEY=sk-... # add to ~/.bashrc to persist
|
||||
```
|
||||
|
||||
### 4. Write a minimum config
|
||||
|
||||
`~/.olp/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"allow_anonymous": false,
|
||||
"owner_only_endpoints": [
|
||||
"/health",
|
||||
"/v0/management/dashboard-data",
|
||||
"/v0/management/quota",
|
||||
"/v0/management/status",
|
||||
"/cache/stats",
|
||||
"/dashboard"
|
||||
],
|
||||
"fallback_detail_header_policy": "owner_only"
|
||||
},
|
||||
"providers": {
|
||||
"enabled": { "anthropic": true, "openai": true }
|
||||
},
|
||||
"routing": {
|
||||
"chains": {
|
||||
"claude-sonnet-4-6": [
|
||||
{ "provider": "anthropic", "model": "claude-sonnet-4-6" },
|
||||
{ "provider": "openai", "model": "gpt-5.5" }
|
||||
],
|
||||
"gpt-5.5": [{ "provider": "openai", "model": "gpt-5.5" }]
|
||||
}
|
||||
},
|
||||
"streaming": { "heartbeat_interval_ms": 15000 }
|
||||
}
|
||||
```
|
||||
|
||||
(Enable only the providers you actually authenticated in step 3. Chains map `<your-IDE's-requested-model>` → ordered list of `{provider, model}` hops; the chain's per-hop `model` is what gets passed to that provider's CLI.)
|
||||
|
||||
### 5. Start the server
|
||||
|
||||
```bash
|
||||
cd ~/olp
|
||||
npm start
|
||||
# OLP v0.4.3 listening on :4567 (2 providers enabled)
|
||||
```
|
||||
|
||||
### 6. Smoke-test
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $OLP_API_KEY" http://localhost:4567/health | jq
|
||||
# Expect: {ok: true, providers: {enabled: 2, status: {anthropic: {ok: true...}, openai: {ok: true...}}}}
|
||||
|
||||
node ~/olp/bin/olp.mjs doctor
|
||||
# Expect: "9 of 9 checks passed", kind=noop
|
||||
```
|
||||
|
||||
### 7. Point your IDE at OLP
|
||||
|
||||
```
|
||||
OPENAI_BASE_URL=http://localhost:4567/v1
|
||||
OPENAI_API_KEY=$OLP_API_KEY
|
||||
```
|
||||
|
||||
Per-IDE configuration details: [`docs/integrations/`](./docs/integrations/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Family / LAN setup
|
||||
|
||||
To let other devices on your home network use the same OLP server, you need TWO things:
|
||||
|
||||
1. **Bind to the LAN interface** (not just loopback). On the SERVER:
|
||||
|
||||
```bash
|
||||
OLP_BIND=0.0.0.0 npm start # or your specific LAN IP, e.g. 192.168.1.10
|
||||
```
|
||||
|
||||
Default is `127.0.0.1` (loopback only). See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — **never set `OLP_BIND=0.0.0.0` on a public-internet-facing host** (use a tunnel like Tailscale instead).
|
||||
|
||||
2. **Onboard each family member's device** from THEIR machine:
|
||||
|
||||
```bash
|
||||
# Pinned to a known-good release (recommended — survives GitHub raw CDN cache hiccups):
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/v0.4.4/bin/olp-connect) <olp-host-ip>
|
||||
|
||||
# OR latest from main (use after v0.4.4 + once you trust head):
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect) <olp-host-ip>
|
||||
```
|
||||
|
||||
Detects Cline / Continue.dev / Cursor / Aider / OpenClaw locally and writes per-tool config pointing at your OLP host. Requires `python3` on the client. Prompts for the OLP API key — OR, if the server has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, picks the token up from `/health.anonymousKey` (zero out-of-band paste). See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
|
||||
|
||||
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Telegram / Discord `/olp` slash command setup: [§ Telegram / Discord Usage](#telegram--discord-usage).
|
||||
|
||||
---
|
||||
|
||||
## Supported Providers
|
||||
@@ -66,12 +233,19 @@ OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned)
|
||||
|
||||
## Configuration
|
||||
|
||||
_placeholder — full configuration reference lands with Phase 4 (fallback engine)._
|
||||
|
||||
OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
|
||||
OLP reads `~/.olp/config.json` at startup. § "[Manual install § Step 4](#4-write-a-minimum-config)" above has a working minimum example. The full schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"allow_anonymous": false,
|
||||
"owner_only_endpoints": ["/health", "/dashboard", "/v0/management/..."],
|
||||
"advertise_anonymous_key": false,
|
||||
"fallback_detail_header_policy": "owner_only"
|
||||
},
|
||||
"providers": {
|
||||
"enabled": { "<provider-key>": true }
|
||||
},
|
||||
"routing": {
|
||||
"chains": {
|
||||
"<requested-model>": [
|
||||
@@ -82,13 +256,25 @@ OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
|
||||
"soft_triggers": {
|
||||
"<provider-key>": { "<trigger>": <threshold> }
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"heartbeat_interval_ms": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `routing.soft_triggers` thresholds are parsed and stored but have **no runtime effect at v0.1** — the quota polling path (`quotaStatus()` per hop) is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). The evaluation logic exists and is tested; only the production data ingestion path is deferred.
|
||||
Field guide:
|
||||
|
||||
Trigger types, fallback safety, idempotency rules, and the full example config land here when Phase 4 ships. See [ADR 0004 (Fallback Engine Semantics & Safety)](./docs/adr/0004-fallback-engine.md) for the design.
|
||||
- **`auth.allow_anonymous`** — default `false`. When false, every request needs a Bearer token; when true, anonymous-tier requests succeed (ADR 0007 § 7). Production posture is `false`.
|
||||
- **`auth.owner_only_endpoints`** — list of endpoints that REQUIRE owner-tier auth (non-owner returns 401). The defaults above are minimum sane for production.
|
||||
- **`auth.advertise_anonymous_key`** — default `false`. When true (+ `allow_anonymous: true` + a key created with `olp-keys keygen --anonymous --advertise`), `/health.anonymousKey` exposes the plaintext token so `olp-connect <ip>` is zero-config. **Trusted-LAN only** — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md).
|
||||
- **`auth.fallback_detail_header_policy`** — controls `X-OLP-Fallback-Detail` response header emission. `owner_only` (default) only shows tuples to owner identity; debug surface to LAN family without leaking to anonymous.
|
||||
- **`providers.enabled`** — flip a provider plugin on. Only enable providers whose CLI you've authenticated; OLP doesn't do its own OAuth.
|
||||
- **`routing.chains`** — keyed by the model name your IDE / client requests. Each entry is an ordered list of fallback hops; each hop's `model` is what gets passed to that provider's CLI. F7 fix (D75) — the hop-level `model` field finally overrides the IR's request model during cross-provider fallback.
|
||||
- **`routing.soft_triggers`** — parsed and stored but **inert at v0.4.x** — the `quotaStatus()` polling data path is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). Startup emits a warn if non-empty so the inert state is visible.
|
||||
- **`streaming.heartbeat_interval_ms`** — default `0` (disabled). Set > 0 (e.g. `15000`) to emit SSE keepalive frames during silent windows. Required behind reverse proxies (nginx / Cloudflare Tunnel / Tailscale Funnel) with 60s idle aborts.
|
||||
|
||||
See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007 (Multi-key auth)](./docs/adr/0007-multi-key-auth.md), [ADR 0010 (Phase 4 charter)](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md), [ADR 0011 (Anonymous-key deployment)](./docs/adr/0011-anonymous-key-deployment-context.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +284,7 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
|
||||
|---|---|---|---|---|
|
||||
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
|
||||
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. |
|
||||
| `/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. |
|
||||
@@ -112,11 +298,30 @@ _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_BIND` | `127.0.0.1` | HTTP listener bind address. **Set to `0.0.0.0` or your LAN IP to accept LAN connections** (required for `olp-connect <ip>` to actually reach the server). Default loopback-only is the secure default. See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — never bind to a public-internet IP. |
|
||||
| `OLP_API_KEY` | (none) | Owner-tier OLP API key (the `olp_...` plaintext from `olp-keys keygen --owner`) used by `olp` CLI subcommands as the bearer for management endpoints. |
|
||||
| `OLP_OWNER_TOKEN` | (none) | Fallback used by `olp` CLI if `OLP_API_KEY` is absent. |
|
||||
| `OLP_PROXY_URL` | `http://127.0.0.1:$OLP_PORT` | Override target URL for `olp` CLI subcommands (so the same binary works against a remote OLP via SSH tunnel or direct LAN). |
|
||||
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
||||
| `OLP_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.
|
||||
@@ -164,9 +369,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 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -197,11 +461,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 shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
|
||||
|
||||
- **Phase 3 (Dashboard + audit query layer + rotation) shipped to main (D48-D54); v0.3.0 release pending.** `docs/adr/0008-dashboard-and-audit-query.md` ratified at D48. `lib/audit-query.mjs` (D49) implements the 5-function aggregate query API (in-memory ndjson scan, PII-guarded). 4 new owner-only_block endpoints at D50 (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`). `dashboard.html` full multi-panel UI at D51 (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause). Daily audit rotation at D52 (synchronous on first append after UTC midnight; `audit-YYYY-MM-DD.ndjson` naming) + optional `bin/olp-audit-rotate.mjs` cron tool. `tried_providers` schema semantic fix at D53 (D45 P2 deferral). Phase 3 close to v0.3.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`.
|
||||
- **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:
|
||||
|
||||
@@ -216,7 +482,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).
|
||||
@@ -263,16 +529,22 @@ Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/proje
|
||||
|
||||
## Migration from OCP
|
||||
|
||||
OLP is OCP's successor. The trigger was Anthropic's 2026-05-14 announcement (effective 2026-06-15) splitting `claude -p` / Agent SDK / third-party agent traffic out of the Pro/Max subscription pool into a separate fixed $100/month Agent SDK credit pool — invalidating OCP's foundational assumption (*"subscription = unlimited within rate limits"*) for its only provider. OLP's structural response is to spread risk across multiple subscriptions whose CLI/programmatic use remains in their main subscription pool, with intelligent fallback when one runs out.
|
||||
|
||||
Beyond the billing trigger, OLP is intentionally NOT a commercial multi-tenant SaaS (LiteLLM / OpenRouter / Portkey already serve that market with funding + SOC2), NOT an enterprise gateway competing on provider breadth, NOT a model-capability router ("route to the smartest model" — you pick the model in `routing.chains`), and NOT a conversation-state store (your client manages its own context). See [ADR 0001](./docs/adr/0001-project-founding.md) for the founding decision and [`ALIGNMENT.md`](./ALIGNMENT.md) for the constitution that governs every plugin / IR / entry-surface change.
|
||||
|
||||
### Migrating an existing OCP install
|
||||
|
||||
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
|
||||
|
||||
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.
|
||||
2. Install OLP (per [§ Manual install](#manual-install-5-10-min) above).
|
||||
3. Run `olp migrate-from-ocp` — will copy `~/.ocp/keys/` to `~/.olp/keys/` and point provider plugins at OCP's existing auth artifacts where applicable.
|
||||
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid.
|
||||
|
||||
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
||||
**Default port moved 3456 → 4567 at v0.4.0** so OCP and OLP can co-host on the same machine during the migration window — set `OLP_PORT=3456` if you want the pre-D60 default. OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Executable
+657
@@ -0,0 +1,657 @@
|
||||
#!/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
|
||||
|
||||
# D78 (G13): derive version from package.json instead of hardcoding (was
|
||||
# stuck at "0.4.0-phase4" through v0.4.1/v0.4.2/v0.4.3 because no one
|
||||
# updated it). Look up package.json next to the script if available;
|
||||
# fall back to "unknown" when running curl-piped (no on-disk package.json).
|
||||
_resolve_version() {
|
||||
local script_dir pkg
|
||||
# When curl-piped (`curl ... | bash`), BASH_SOURCE[0] is empty → dirname
|
||||
# yields "." → script_dir resolves to cwd. D78 reviewer P2-1 hardening:
|
||||
# require the suffix-strip to actually fire (script_dir ENDED with /bin),
|
||||
# otherwise we'd happily pick up an unrelated package.json from whatever
|
||||
# directory the user happens to be in when piping. Belt-and-braces.
|
||||
# ${BASH_SOURCE[0]:-} default-empty guards against `set -u` nounset error
|
||||
# when invoked via `curl ... | bash` (no source file → BASH_SOURCE unset).
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-}")" &>/dev/null && pwd)"
|
||||
if [[ "$script_dir" != */bin ]]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
pkg="${script_dir%/bin}/package.json"
|
||||
# D78 reviewer P2-2: pass $pkg via env var instead of -c interpolation
|
||||
# so paths with apostrophes / shell metacharacters can't break the
|
||||
# python invocation. Canonical layout is safe; this is defense-in-depth.
|
||||
if [[ -f "$pkg" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
OLP_PKG_PATH="$pkg" python3 -c 'import json,os;print(json.load(open(os.environ["OLP_PKG_PATH"])).get("version","unknown"))' 2>/dev/null || echo "unknown"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
OLP_CONNECT_VERSION="$(_resolve_version)"
|
||||
|
||||
show_version() {
|
||||
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. Phase 4 D71-D73 shipped olp-plugin/ as the OpenClaw
|
||||
# gateway plugin for /olp Telegram + Discord slash commands. Point users
|
||||
# at the install path.
|
||||
detect_openclaw() {
|
||||
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
|
||||
log_info ""
|
||||
log_info "Detected: OpenClaw"
|
||||
log_info " OLP ships an OpenClaw gateway plugin for /olp Telegram + Discord"
|
||||
log_info " slash commands (status / usage / cache / models / providers /"
|
||||
log_info " chain show / health / doctor). Read-only by design — no chat-side"
|
||||
log_info " mutations."
|
||||
log_info ""
|
||||
log_info " Install the plugin (one-time, on the host running OpenClaw):"
|
||||
log_info " git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo"
|
||||
log_info " openclaw plugins install /tmp/olp-repo/olp-plugin"
|
||||
log_info " # OR symlink: ln -sf /tmp/olp-repo/olp-plugin ~/.openclaw/extensions/olp"
|
||||
log_info ""
|
||||
log_info " Then edit ~/.openclaw/openclaw.json to set the plugin apiKey to a"
|
||||
log_info " dedicated OLP key (NOT your owner key — create one via olp-keys"
|
||||
log_info " keygen --name <bot-name>). Restart OpenClaw gateway."
|
||||
log_info ""
|
||||
log_info " See docs/integrations/openclaw.md for full instructions."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 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
+774
@@ -0,0 +1,774 @@
|
||||
#!/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}`);
|
||||
// D75 F4 fix: server payload nests cache stats under stats.cache (per
|
||||
// server.mjs handleManagementStatus, ~line 2092). The CacheStore.stats()
|
||||
// contract returns { hits, misses, size, inflightCount } per
|
||||
// lib/cache/store.mjs — there is no `entries` field. Pre-D75 cmdStatus read
|
||||
// `body.stats.cache.entries` (OCP-era) which was always undefined → output
|
||||
// showed "entries=?". Same pattern as D74 P2-3 applied to cmdCache/cmdUsage.
|
||||
if (body.stats?.cache) {
|
||||
const c = body.stats.cache;
|
||||
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.size ?? 0}${typeof c.inflightCount === 'number' ? ` inflight=${c.inflightCount}` : ''}`);
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ The dashboard sets a 30s `setInterval` that calls `fetch('/v0/management/dashboa
|
||||
|
||||
### 6.6 Localhost-bound by default
|
||||
|
||||
The dashboard is served from the existing OLP HTTP port (default 3456) which is already bound to `127.0.0.1` per `server.mjs` startup (`server.listen(PORT, '127.0.0.1', ...)`). No additional binding logic. Remote operators access via SSH tunnel; ADR 0007 § 7 owner-only auth provides the per-request gate.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,358 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Deployment configurations (D76 amendment, 2026-05-26)
|
||||
|
||||
Original ADR 0011 referenced a `BIND_ADDRESS` concept that did not exist in the v0.4.0–v0.4.2 codebase — the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`. D76 closes this gap by adding the `OLP_BIND` env var (default `127.0.0.1`), making the deployment-context discussion below operational rather than aspirational.
|
||||
|
||||
Three deployment configurations are supported:
|
||||
|
||||
| `OLP_BIND` value | Reachability | Anonymous-key publication |
|
||||
|---|---|---|
|
||||
| `127.0.0.1` (default) | Loopback only | Safe with any auth posture (no LAN exposure at all) |
|
||||
| RFC1918 IP / tailnet IP / `0.0.0.0` on a trusted LAN | LAN clients only | Safe when `advertise_anonymous_key: true` — the documented "trusted-LAN" zero-config family onboarding flow |
|
||||
| Public IP / `0.0.0.0` on a public-facing host | Public internet | **Incompatible with `advertise_anonymous_key: true`.** Operator MUST keep `advertise_anonymous_key: false` (default). |
|
||||
|
||||
The server emits a startup warn event `anonymous_key_advertised_with_lan_bind` when `OLP_BIND` is non-loopback AND `advertise_anonymous_key: true` (per the `lib/keys.mjs` + `server.mjs` checks). The warn is a **checkpoint, not a hard gate** — the server cannot tell from the bind address alone whether the operator is on a trusted LAN (RFC1918 / tailnet) or has accidentally exposed a public IP. The Re-evaluation trigger #1 below escalates to a hard gate when OLP gains a public-internet deployment mode.
|
||||
|
||||
`olp-connect <ip>` consumes `/health.anonymousKey` over the network — therefore requires `OLP_BIND` to include the LAN interface on the server side. Without setting `OLP_BIND=<lan-ip>` (or `0.0.0.0`), `olp-connect <ip>` will fail with `connect ECONNREFUSED` because the server only accepts loopback connections.
|
||||
|
||||
---
|
||||
|
||||
## Re-evaluation triggers
|
||||
|
||||
Re-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.
|
||||
@@ -23,6 +23,8 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
|
||||
| [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)
|
||||
|
||||
|
||||
+20
-6
@@ -227,7 +227,7 @@ export function* readAuditWindow({ startMs, endMs, olpHome, logEvent } = {}) {
|
||||
* {
|
||||
* window: { startMs, endMs },
|
||||
* request_count, status_2xx, status_4xx, status_5xx,
|
||||
* by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, fallback_count } },
|
||||
* 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,
|
||||
@@ -276,12 +276,17 @@ export function aggregateRequests({ windowMs, olpHome, logEvent, _nowFn } = {})
|
||||
// By provider
|
||||
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
|
||||
const p = result.by_provider[ev.provider] ??= {
|
||||
count: 0, cache_hit: 0, cache_miss: 0, cache_bypass: 0, fallback_count: 0,
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -443,7 +448,12 @@ export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
|
||||
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
||||
* this is the audit-side rate scoped to the rolling window.
|
||||
*
|
||||
* { window: { startMs, endMs }, total, hit, miss, bypass, hit_rate, by_provider }
|
||||
* { 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
|
||||
@@ -459,18 +469,22 @@ export function cacheHitRateWindow({ windowMs, olpHome, logEvent, _nowFn } = {})
|
||||
const startMs = now - windowMs;
|
||||
const endMs = now;
|
||||
|
||||
let total = 0, hit = 0, miss = 0, bypass = 0;
|
||||
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, hit_rate: 0 };
|
||||
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
|
||||
@@ -484,6 +498,6 @@ export function cacheHitRateWindow({ windowMs, olpHome, logEvent, _nowFn } = {})
|
||||
|
||||
return {
|
||||
window: { startMs, endMs },
|
||||
total, hit, miss, bypass, hit_rate, by_provider,
|
||||
total, hit, miss, bypass, streaming_attached, hit_rate, by_provider,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,20 @@ export function _maybeRotateAudit(args = {}) {
|
||||
* 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]
|
||||
|
||||
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 {
|
||||
|
||||
+163
-3
@@ -157,6 +157,36 @@ function resolveCodexBin() {
|
||||
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
|
||||
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
|
||||
//
|
||||
// ── D75 (v0.4.2) F1 — codex CLI v0.133.0 schema pin ─────────────────────────
|
||||
//
|
||||
// Real codex CLI v0.133.0 auth.json (verified empirically on PI231 / Mac mini,
|
||||
// 2026-05-26 E2E session):
|
||||
//
|
||||
// {
|
||||
// "auth_mode": "chatgpt",
|
||||
// "OPENAI_API_KEY": null | "<key>",
|
||||
// "tokens": {
|
||||
// "id_token": "<JWT>",
|
||||
// "access_token": "<opaque-or-JWT>", <-- THIS is the access token
|
||||
// "refresh_token": "<opaque>",
|
||||
// "account_id": "<uuid>"
|
||||
// },
|
||||
// "last_refresh": "<ISO8601>"
|
||||
// }
|
||||
//
|
||||
// D6 assumption A3 originally tried `creds.access_token` at TOP level. Under
|
||||
// codex v0.133.0 this field does not exist at the top level → readAuthArtifact()
|
||||
// returned null even when the user had fully completed `codex login`. OLP then
|
||||
// reported "auth artifact missing" via /health and `olp doctor`, and refused
|
||||
// to spawn codex — false negative blocking the entire openai provider.
|
||||
//
|
||||
// Fix: prepend `creds?.tokens?.access_token` to the precedence chain. Keep all
|
||||
// existing fallbacks unchanged so older codex CLI versions (pre-v0.133) and any
|
||||
// future shape variants still resolve.
|
||||
//
|
||||
// Authority pin: codex CLI v0.133.0 source + on-disk auth.json captured during
|
||||
// PI231 E2E. See D75 commit body for the verification transcript.
|
||||
//
|
||||
// Returns { accessToken: string } or null (never throws).
|
||||
export function readAuthArtifact() {
|
||||
// 1. Explicit test override — always takes precedence.
|
||||
@@ -165,7 +195,12 @@ export function readAuthArtifact() {
|
||||
try {
|
||||
const raw = readFileSync(authPathOverride, 'utf8');
|
||||
const creds = JSON.parse(raw);
|
||||
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
|
||||
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
|
||||
// Preserve top-level fallbacks for backward / forward compat.
|
||||
const token = creds?.tokens?.access_token
|
||||
?? creds?.access_token
|
||||
?? creds?.token
|
||||
?? creds?.accessToken;
|
||||
if (token && typeof token === 'string') return { accessToken: token };
|
||||
} catch { /* fall through */ }
|
||||
return null; // explicit path set but file missing / malformed
|
||||
@@ -178,8 +213,12 @@ export function readAuthArtifact() {
|
||||
try {
|
||||
const raw = readFileSync(authPath, 'utf8');
|
||||
const creds = JSON.parse(raw);
|
||||
// D6 assumption A3: try common OAuth field names in precedence order.
|
||||
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
|
||||
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
|
||||
// Try the nested location FIRST, then fall back to legacy top-level fields.
|
||||
const token = creds?.tokens?.access_token
|
||||
?? creds?.access_token
|
||||
?? creds?.token
|
||||
?? creds?.accessToken;
|
||||
if (token && typeof token === 'string') return { accessToken: token };
|
||||
} catch { /* file missing or malformed */ }
|
||||
|
||||
@@ -242,9 +281,30 @@ export function irToCodex(irRequest) {
|
||||
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
|
||||
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
|
||||
// from stdin."
|
||||
//
|
||||
// ── D75 (v0.4.2) F2 — codex CLI v0.133.0 trusted-directory sandbox ─────────
|
||||
// codex CLI v0.133.0 added a trusted-directory sandbox: invocations outside
|
||||
// a git repo (or outside any directory explicitly trusted via
|
||||
// `codex config trusted-directories`) refuse with:
|
||||
// "Not inside a trusted directory and --skip-git-repo-check was not specified."
|
||||
// and exit non-zero with zero NDJSON output → OLP surfaces SPAWN_FAILED with
|
||||
// no usable chunks → fallback engine advances to next hop unnecessarily.
|
||||
//
|
||||
// The CWD that OLP spawns from is typically the server install dir (`~/olp/`
|
||||
// on Pi231) which is a git repo on maintainer workstations but is NOT a git
|
||||
// repo on most operator hosts. We bypass the sandbox unconditionally because
|
||||
// OLP is the trusted caller (it is the operator's own server invoking its own
|
||||
// configured Codex subscription via the documented `codex exec` automation
|
||||
// entry point). The trusted-directory sandbox is a foot-gun safeguard for
|
||||
// interactive users; OLP's spawn is non-interactive and pre-authorized.
|
||||
//
|
||||
// Authority: codex CLI v0.133.0 release notes / `codex exec --help` output
|
||||
// documenting `--skip-git-repo-check`. Verified empirically on PI231 E2E
|
||||
// 2026-05-26.
|
||||
const args = [
|
||||
'exec',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'--model', irRequest.model,
|
||||
];
|
||||
|
||||
@@ -291,6 +351,51 @@ export function codexChunkToIR(rawNDJSONLine) {
|
||||
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
|
||||
// ── D75 (v0.4.2) F3 — codex CLI v0.133.0 event shape pin ─────────────────
|
||||
// Real codex CLI v0.133.0 NDJSON event stream (verified empirically on PI231
|
||||
// / Mac mini, 2026-05-26 E2E session):
|
||||
// {"type":"thread.started","thread_id":"019e..."}
|
||||
// {"type":"turn.started"}
|
||||
// {"type":"item.started","item":{"id":"item_0","type":"reasoning","text":""}}
|
||||
// {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}
|
||||
// {"type":"turn.completed","usage":{"input_tokens":..,"output_tokens":..}}
|
||||
//
|
||||
// The D6 defensive parser recognized `content`/`delta`/`text` fields at the
|
||||
// top level and `type === 'stop'`/`done === true`. None of these match
|
||||
// v0.133.0's actual shape → every chunk was silently dropped → response body
|
||||
// had `content: null`. F3 adds three NEW recognizers (item.completed →
|
||||
// agent_message; turn.completed → stop; turn.failed → error) BEFORE the
|
||||
// legacy fallback chain. Legacy recognizers preserved for forward/backward
|
||||
// compat (older codex versions; future shape variants).
|
||||
|
||||
// F3-a: agent_message item completion.
|
||||
// codex v0.133.0 emits assistant text as a single item.completed event whose
|
||||
// item.type is 'agent_message' and item.text carries the full text. There
|
||||
// are no incremental deltas — the entire response arrives in one chunk.
|
||||
if (event.type === 'item.completed'
|
||||
&& event.item?.type === 'agent_message'
|
||||
&& typeof event.item?.text === 'string') {
|
||||
return { type: 'delta', content: event.item.text };
|
||||
}
|
||||
|
||||
// F3-b: turn completion → stop chunk.
|
||||
// codex v0.133.0 emits turn.completed with a usage block when the model
|
||||
// finishes. We map this to IR stop with finish_reason 'stop'.
|
||||
if (event.type === 'turn.completed') {
|
||||
return { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
|
||||
// F3-c: turn failure → error chunk.
|
||||
// codex v0.133.0 emits turn.failed with an embedded error object when the
|
||||
// turn cannot complete. Extract a human-readable message for the IR error.
|
||||
if (event.type === 'turn.failed') {
|
||||
const errMsg = (typeof event.error === 'string')
|
||||
? event.error
|
||||
: (event.error?.message ?? 'codex turn.failed');
|
||||
return { type: 'error', error: errMsg };
|
||||
}
|
||||
|
||||
// ── Legacy/fallback recognizers (kept for backward + forward compat) ────
|
||||
// Error event: type === 'error' or error field present
|
||||
// A4: defensive — error shape unconfirmed; D7 will pin actual field names
|
||||
if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
|
||||
@@ -637,6 +742,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 +820,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"
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -1,19 +1,36 @@
|
||||
{
|
||||
"name": "olp",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.4",
|
||||
"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": "./bin/olp.mjs",
|
||||
"olp-keys": "./bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "./bin/olp-audit-rotate.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": "node bin/olp.mjs",
|
||||
"olp-keys": "node bin/olp-keys.mjs",
|
||||
"olp-audit-rotate": "node bin/olp-audit-rotate.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"
|
||||
},
|
||||
|
||||
+758
-195
File diff suppressed because it is too large
Load Diff
+3406
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user