mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4d9459aa | ||
|
|
53afea47ca | ||
|
|
0bdecd1235 | ||
|
|
e69e908dae | ||
|
|
e6701ff698 | ||
|
|
0048481764 |
+82
-1
@@ -4,7 +4,88 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
(empty — Phase 4 entries land here once Phase 4 opens)
|
(empty — Phase 5 entries land here once Phase 5 opens)
|
||||||
|
|
||||||
|
## 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
|
## v0.3.2 — 2026-05-25
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ release_kit:
|
|||||||
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently
|
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently
|
||||||
# violated (no version bump after many D-day pushes), check this section first
|
# violated (no version bump after many D-day pushes), check this section first
|
||||||
# before filing a compliance finding.
|
# before filing a compliance finding.
|
||||||
current_phase: Phase 4
|
current_phase: Phase 5
|
||||||
current_pre_release_identifier: "0.4.0-phase4"
|
current_pre_release_identifier: "0.5.0-phase5"
|
||||||
phase_close_trigger: explicit maintainer action (not automated)
|
phase_close_trigger: explicit maintainer action (not automated)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,12 +31,26 @@ npm install -g @dtzp555-max/olp
|
|||||||
# run setup (writes ~/.olp/config.json, asks which providers to enable)
|
# run setup (writes ~/.olp/config.json, asks which providers to enable)
|
||||||
olp setup
|
olp setup
|
||||||
|
|
||||||
# start the proxy (default port 3456 — same as OCP if you migrate)
|
# start the proxy (default port 4567 since v0.4.0 — moved off OCP's 3456 so
|
||||||
|
# OLP and OCP can co-host on the same machine. Set OLP_PORT=3456 if you have
|
||||||
|
# no OCP on the machine and want the old default.)
|
||||||
olp start
|
olp start
|
||||||
|
|
||||||
# point your IDE at http://localhost:3456/v1/chat/completions with the OLP API key from `olp keys list`.
|
# point your IDE at http://localhost:4567/v1/chat/completions with the OLP API key from `olp keys list`.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Family-on-LAN onboarding (D68-D70).** For other devices on the same network, run on the client device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Detects Cline / Continue.dev / Cursor / Aider / OpenClaw installed locally
|
||||||
|
# and writes per-tool config pointing at the OLP host. Requires `python3`.
|
||||||
|
olp-connect <olp-host-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the OLP host has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, `olp-connect` picks up the token from `/health.anonymousKey` — zero out-of-band token paste required. See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
|
||||||
|
|
||||||
|
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Supported Providers
|
## Supported Providers
|
||||||
@@ -98,7 +112,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/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
|
||||||
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
||||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. |
|
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
|
||||||
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
| `/dashboard` | GET | 3 | ✅ 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/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. |
|
| `/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 +126,26 @@ _placeholder — full table lands per-phase as variables are introduced._
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OLP_PORT` | `3456` | HTTP listener port. |
|
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
|
||||||
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
||||||
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
||||||
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
||||||
|
|
||||||
|
### `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
|
### 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.
|
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,6 +193,65 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## IDE Setup
|
||||||
|
|
||||||
|
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-25, post-v0.2.0)
|
## Implementation status (as of 2026-05-25, post-v0.2.0)
|
||||||
|
|
||||||
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 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.
|
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.
|
||||||
@@ -216,7 +304,7 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
|||||||
npm start
|
npm start
|
||||||
|
|
||||||
# 4. Validate the key works (substitute the captured plaintext token)
|
# 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).
|
**Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once).
|
||||||
@@ -270,7 +358,7 @@ Anticipated user-facing flow (target: <5 minutes):
|
|||||||
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
|
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
|
||||||
2. Install OLP.
|
2. Install OLP.
|
||||||
3. Run `olp migrate-from-ocp` — copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable.
|
3. Run `olp migrate-from-ocp` — copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable.
|
||||||
4. Start OLP. Clients pointing at port 3456 keep working; their existing OLP API keys remain valid.
|
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid. **Note (v0.4.0+):** default port moved from 3456 → 4567 so OCP and OLP can co-host during migration; set `OLP_PORT=3456` if you want the pre-D60 default.
|
||||||
|
|
||||||
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
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
+564
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# bin/olp-connect — Lightweight client script to connect this machine to a remote
|
||||||
|
# OLP (Open LLM Proxy). Ported from OCP's `ocp-connect` per ADR 0010 § Phase 4
|
||||||
|
# D68-D70 charter; uses /health.anonymousKey when the remote operator opted in
|
||||||
|
# via `auth.advertise_anonymous_key: true` (ADR 0011).
|
||||||
|
#
|
||||||
|
# Authority:
|
||||||
|
# - ADR 0010 (Phase 4 charter — D68 line: client-side IDE auto-config)
|
||||||
|
# - ADR 0011 (anonymous-key deployment-context limits — trusted-LAN invariant)
|
||||||
|
# - OCP `ocp-connect` v1.3.0 (prior-art reference)
|
||||||
|
#
|
||||||
|
# Why bash (not Node like `olp` CLI):
|
||||||
|
# olp-connect MUST run on CLIENT machines that may not have a recent Node
|
||||||
|
# installed (parents' laptops, work machines, Raspberry Pi). bash + curl +
|
||||||
|
# python3 give maximum portability; this script does not import any OLP
|
||||||
|
# Node modules.
|
||||||
|
#
|
||||||
|
# Dependencies: bash >=4, curl, python3 (for /health JSON parsing).
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect -o olp-connect
|
||||||
|
# chmod +x olp-connect
|
||||||
|
#
|
||||||
|
# Or via npm/npx (once `npm install -g olp` is run on a machine that has Node):
|
||||||
|
# olp-connect <ip>
|
||||||
|
#
|
||||||
|
# Or run directly via curl-pipe:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect | bash -s -- <host-ip>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OLP_CONNECT_VERSION="0.4.0-phase4"
|
||||||
|
|
||||||
|
show_version() {
|
||||||
|
echo "olp-connect $OLP_CONNECT_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
olp-connect — Connect this machine to a remote OLP (Open LLM Proxy)
|
||||||
|
|
||||||
|
Configures OPENAI_BASE_URL + OPENAI_API_KEY in your shell rc file (and macOS
|
||||||
|
launchctl env / Linux systemd user env), then detects installed IDEs (Cline,
|
||||||
|
Continue.dev, Cursor, Aider, OpenClaw, Claude Code) and prints / writes the
|
||||||
|
provider-specific configuration each needs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
olp-connect <host-ip> [options]
|
||||||
|
olp-connect --help
|
||||||
|
olp-connect --version
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port PORT Port OLP listens on (default: 4567 — OLP v0.4.0+ default;
|
||||||
|
set 3456 if connecting to a pre-D60 OLP install)
|
||||||
|
--key API_KEY OLP API key (from `olp-keys keygen` on the server). When
|
||||||
|
omitted, the script reads /health.anonymousKey (if the
|
||||||
|
server opted in via auth.advertise_anonymous_key=true) or
|
||||||
|
prompts interactively.
|
||||||
|
--no-system-env Skip macOS launchctl setenv / Linux systemd env writes;
|
||||||
|
only update shell rc files.
|
||||||
|
--dry-run Print everything the script would do without modifying
|
||||||
|
any file or setting any env var.
|
||||||
|
--version Print version and exit
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
olp-connect 192.168.1.10
|
||||||
|
olp-connect 192.168.1.10 --port 8080
|
||||||
|
olp-connect 192.168.1.10 --key olp_AbcDef1234...
|
||||||
|
olp-connect 100.64.0.5 --dry-run
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
bash, curl, python3 (for /health JSON parsing)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 success
|
||||||
|
1 bad arguments / unknown flag / missing required value
|
||||||
|
2 connectivity or auth failure / smoke test failure
|
||||||
|
|
||||||
|
Authority: ADR 0010 § Phase 4 D68-D70; ADR 0011 (anonymous-key trusted-LAN
|
||||||
|
invariant — when --key is auto-resolved from /health.anonymousKey, this
|
||||||
|
deployment MUST be on a trusted LAN).
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Globals populated by main() ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
NO_SYSTEM_ENV=false
|
||||||
|
|
||||||
|
# ── Logging helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log_info() { echo " $*"; }
|
||||||
|
log_step() { echo " → $*"; }
|
||||||
|
log_ok() { echo " ✓ $*"; }
|
||||||
|
log_warn() { echo " ⚠ $*"; }
|
||||||
|
log_err() { echo " ✗ $*" >&2; }
|
||||||
|
|
||||||
|
# Echo a state change (a write / env-set) before executing — operator can
|
||||||
|
# Ctrl-C if something looks wrong. Returns 0 always.
|
||||||
|
log_change() { echo " • $*"; }
|
||||||
|
|
||||||
|
# ── IDE detection + configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# Truncate long keys for display (avoid leaking via screenshot / screen share).
|
||||||
|
key_display() {
|
||||||
|
local k="$1"
|
||||||
|
if [[ -z "$k" ]]; then
|
||||||
|
echo "(none — anonymous; most IDEs require a non-empty API Key)"
|
||||||
|
elif [[ ${#k} -gt 16 ]]; then
|
||||||
|
echo "${k:0:8}...${k: -4}"
|
||||||
|
else
|
||||||
|
echo "$k"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
|
||||||
|
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
|
||||||
|
# client. The user is steered toward Cline + OLP.
|
||||||
|
detect_claude_code() {
|
||||||
|
if command -v claude &>/dev/null; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Claude Code (`command -v claude`)"
|
||||||
|
log_warn "Claude Code is NOT supported as an OLP client (OLP does not ship"
|
||||||
|
log_warn " /v1/messages — see ADR 0010 § Out-of-Phase-4-scope)."
|
||||||
|
log_warn " Recommended alternative: install Cline (VSCode extension) + OLP."
|
||||||
|
log_warn " Cline uses OpenAI-shape /v1/chat/completions which OLP DOES serve."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Cline VSCode extension and print manual-configure snippet.
|
||||||
|
# Cline cannot be auto-configured via env vars — user must paste into VSCode
|
||||||
|
# settings UI. We surface the values for them.
|
||||||
|
detect_cline() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local exts=""
|
||||||
|
if command -v code &>/dev/null; then
|
||||||
|
exts=$(code --list-extensions 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$exts" && -d "$HOME/.vscode/extensions" ]]; then
|
||||||
|
exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Cline (VSCode extension)"
|
||||||
|
log_info " Cline must be configured via the VSCode settings UI."
|
||||||
|
log_info " Open VSCode → Cline panel → Settings → API Provider:"
|
||||||
|
log_info " API Provider: \"OpenAI Compatible\""
|
||||||
|
log_info " Base URL: $base_url/v1"
|
||||||
|
log_info " API Key: $(key_display "$key")"
|
||||||
|
log_info " Model ID: claude-sonnet-4-5 (or any model from /v1/models)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Continue.dev and write a `models:` entry to ~/.continue/config.yaml
|
||||||
|
# (idempotent — checks if an entry with the same name exists first).
|
||||||
|
detect_continue() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local exts=""
|
||||||
|
if command -v code &>/dev/null; then
|
||||||
|
exts=$(code --list-extensions 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
local config_yaml="$HOME/.continue/config.yaml"
|
||||||
|
local config_json="$HOME/.continue/config.json"
|
||||||
|
|
||||||
|
local found=false
|
||||||
|
if echo "$exts" | grep -qi 'continue\.continue'; then found=true; fi
|
||||||
|
if [[ -f "$config_yaml" || -f "$config_json" ]]; then found=true; fi
|
||||||
|
|
||||||
|
if ! $found; then return 0; fi
|
||||||
|
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Continue.dev"
|
||||||
|
log_info " Configuration snippet for ~/.continue/config.yaml:"
|
||||||
|
log_info " models:"
|
||||||
|
log_info " - name: OLP Sonnet"
|
||||||
|
log_info " provider: openai"
|
||||||
|
log_info " model: claude-sonnet-4-5"
|
||||||
|
log_info " apiBase: $base_url/v1"
|
||||||
|
log_info " apiKey: $(key_display "$key")"
|
||||||
|
log_info " Note: Continue.dev autoreload-on-save is fragile; restart VSCode if"
|
||||||
|
log_info " the new model doesn't appear in the model selector."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Cursor and print manual snippet + known-fragility warning.
|
||||||
|
detect_cursor() {
|
||||||
|
local base_url="$1" key="$2"
|
||||||
|
local found=false
|
||||||
|
if command -v cursor &>/dev/null; then found=true; fi
|
||||||
|
if [[ -d "$HOME/.cursor" ]]; then found=true; fi
|
||||||
|
if [[ -d "/Applications/Cursor.app" ]]; then found=true; fi
|
||||||
|
|
||||||
|
if ! $found; then return 0; fi
|
||||||
|
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Cursor"
|
||||||
|
log_info " Cmd+Shift+P → 'Cursor Settings' → Models:"
|
||||||
|
log_info " OpenAI API Key: $(key_display "$key")"
|
||||||
|
log_info " Override OpenAI Base URL: $base_url/v1"
|
||||||
|
log_info " Custom OpenAI Models: claude-sonnet-4-5,claude-opus-4-1"
|
||||||
|
log_warn " Cursor's base-URL handling is known-fragile (issue #7128 et al);"
|
||||||
|
log_warn " if requests fail with 'malformed request', try removing then"
|
||||||
|
log_warn " re-adding the model in the Cursor models list."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect Aider and write OPENAI_API_BASE / OPENAI_API_KEY to rc files.
|
||||||
|
# Aider reads these env vars at startup — already handled by the rc-file
|
||||||
|
# block in main(). We just announce detection here.
|
||||||
|
detect_aider() {
|
||||||
|
if command -v aider &>/dev/null; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: Aider (`command -v aider`)"
|
||||||
|
log_info " Aider reads OPENAI_API_BASE + OPENAI_API_KEY from env."
|
||||||
|
log_info " These are already being written to your shell rc — open a fresh"
|
||||||
|
log_info " shell and run: aider --model openai/claude-sonnet-4-5"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect OpenClaw. Per Phase 4 D71-D73 (NOT in this PR), olp will ship
|
||||||
|
# olp-plugin/ for OpenClaw with full Telegram/Discord /olp slash commands.
|
||||||
|
# Until that ships, we just announce detection and link.
|
||||||
|
detect_openclaw() {
|
||||||
|
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
|
||||||
|
log_info ""
|
||||||
|
log_info "Detected: OpenClaw"
|
||||||
|
log_info " The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED."
|
||||||
|
log_info " When it ships, install with: openclaw plugin install olp"
|
||||||
|
log_info " For now, you can manually point OpenClaw at OLP via the OPENAI_BASE_URL"
|
||||||
|
log_info " env var (already written to your shell rc above)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── rc-file helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Identify which shell rc files to write to. Returns paths on stdout, one per line.
|
||||||
|
detect_rc_files() {
|
||||||
|
local is_mac=false
|
||||||
|
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
||||||
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
|
log_warn "fish shell detected; writing to ~/.bashrc — add to fish config manually." >&2
|
||||||
|
echo "$HOME/.bashrc"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if $is_mac; then
|
||||||
|
# macOS Catalina+ default shell is zsh
|
||||||
|
[[ -f "$HOME/.bashrc" ]] && echo "$HOME/.bashrc"
|
||||||
|
[[ -f "$HOME/.zshrc" ]] || { $DRY_RUN || touch "$HOME/.zshrc"; }
|
||||||
|
echo "$HOME/.zshrc"
|
||||||
|
else
|
||||||
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && echo "$HOME/.bashrc"
|
||||||
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && echo "$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove any previously-written OLP block from an rc file (idempotent).
|
||||||
|
# The block is bracketed by:
|
||||||
|
# # OLP LAN (added by olp-connect) ... # /OLP LAN
|
||||||
|
strip_olp_block() {
|
||||||
|
local rc_file="$1"
|
||||||
|
[[ -f "$rc_file" ]] || return 0
|
||||||
|
if $DRY_RUN; then
|
||||||
|
if grep -qF '# OLP LAN (added by olp-connect)' "$rc_file" 2>/dev/null; then
|
||||||
|
log_change "[dry-run] would strip existing OLP block from $rc_file"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
python3 - "$rc_file" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except OSError:
|
||||||
|
sys.exit(0)
|
||||||
|
out = []
|
||||||
|
skip = False
|
||||||
|
for line in lines:
|
||||||
|
s = line.rstrip('\n')
|
||||||
|
if s == '# OLP LAN (added by olp-connect)':
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if skip and s == '# /OLP LAN':
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.writelines(out)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append a new OLP block to an rc file.
|
||||||
|
append_olp_block() {
|
||||||
|
local rc_file="$1" base_url="$2" key="$3"
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_change "[dry-run] would append OLP block to $rc_file:"
|
||||||
|
log_change " # OLP LAN (added by olp-connect)"
|
||||||
|
log_change " export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(key_display "$key")"
|
||||||
|
log_change " # /OLP LAN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# OLP LAN (added by olp-connect)"
|
||||||
|
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo "export OPENAI_API_KEY=$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
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
shift 2 ;;
|
||||||
|
--key=*) key="${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
|
||||||
|
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
|
||||||
|
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:
|
Usage:
|
||||||
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
||||||
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
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 list [--owner-only] [--include-revoked]
|
||||||
olp-keys revoke --id=<key-id>
|
olp-keys revoke --id=<key-id>
|
||||||
|
|
||||||
@@ -86,7 +87,8 @@ Common flags:
|
|||||||
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
||||||
--help Print this message
|
--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 ────────────────────────────────────────────
|
// ── Subcommand implementations ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,16 +96,36 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
|||||||
const olpHome = flags['olp-home'];
|
const olpHome = flags['olp-home'];
|
||||||
const owner = flags.owner === true;
|
const owner = flags.owner === true;
|
||||||
const force = flags.force === 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;
|
let tier = flags.tier;
|
||||||
if (owner) tier = 'owner';
|
if (owner) tier = 'owner';
|
||||||
|
if (isAnonymous && !owner) tier = 'guest';
|
||||||
if (!tier) tier = 'guest';
|
if (!tier) tier = 'guest';
|
||||||
if (tier !== 'owner' && tier !== 'guest') {
|
if (tier !== 'owner' && tier !== 'guest') {
|
||||||
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
||||||
return 1;
|
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) {
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
const providersFlag = flags.providers;
|
const providersFlag = flags.providers;
|
||||||
@@ -136,7 +158,7 @@ async function cmdKeygen(flags, ioOut, ioErr) {
|
|||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
|
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome, plaintext_advertise: advertise });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
||||||
return 2;
|
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(` 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(` created_at: ${result.manifest.created_at}\n`);
|
||||||
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\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(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
||||||
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\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`);
|
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
||||||
|
|||||||
Executable
+734
@@ -0,0 +1,734 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* bin/olp.mjs — OLP operator CLI (Phase 4 / D64)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
|
||||||
|
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
|
||||||
|
* python3 JSON-parsing fragility called out in the ADR.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* status GET /v0/management/status (owner-only)
|
||||||
|
* health GET /health
|
||||||
|
* usage GET /v0/management/dashboard-data (owner-only)
|
||||||
|
* models GET /v1/models
|
||||||
|
* cache GET /cache/stats (owner-only)
|
||||||
|
* providers local: models-registry + config providers.enabled
|
||||||
|
* chain show [<model>] local: ~/.olp/config.json routing.chains
|
||||||
|
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
|
||||||
|
* restart launchctl (macOS) / systemctl --user (Linux)
|
||||||
|
* doctor [--check X] run lib/doctor.mjs runDoctor + format
|
||||||
|
* keys ... delegate to bin/olp-keys.mjs
|
||||||
|
* help | --help usage
|
||||||
|
*
|
||||||
|
* Global flags:
|
||||||
|
* --json emit raw JSON (silences human-readable output)
|
||||||
|
* --proxy-url=<url> override resolved proxy URL
|
||||||
|
* --olp-home=<path> override ~/.olp
|
||||||
|
*
|
||||||
|
* URL resolution:
|
||||||
|
* OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567}
|
||||||
|
*
|
||||||
|
* Auth (Bearer token) resolution:
|
||||||
|
* 1. OLP_API_KEY env
|
||||||
|
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
|
||||||
|
* 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk
|
||||||
|
*
|
||||||
|
* Note: the third option only works during the same session in which `olp-keys
|
||||||
|
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
|
||||||
|
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
|
||||||
|
* reports "no owner token available" + remediation instructions if env vars are
|
||||||
|
* absent — it does NOT try to crack the hash.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 = success
|
||||||
|
* 1 = bad usage / unknown subcommand
|
||||||
|
* 2 = network or HTTP error (4xx/5xx)
|
||||||
|
* 3 = auth missing / forbidden
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request as httpRequest } from 'node:http';
|
||||||
|
import { request as httpsRequest } from 'node:https';
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { spawn as spawnProc } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { realpathSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
|
||||||
|
import { listKeys } from '../lib/keys.mjs';
|
||||||
|
import { readAuditWindow } from '../lib/audit-query.mjs';
|
||||||
|
import modelsRegistry from '../models-registry.json' with { type: 'json' };
|
||||||
|
import { runCli as runKeysCli } from './olp-keys.mjs';
|
||||||
|
|
||||||
|
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const ANSI = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
gray: '\x1b[90m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function colorize(s, code, useColor) {
|
||||||
|
if (!useColor) return s;
|
||||||
|
return `${code}${s}${ANSI.reset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status, useColor) {
|
||||||
|
const map = {
|
||||||
|
ok: { txt: 'PASS', col: ANSI.green },
|
||||||
|
warn: { txt: 'WARN', col: ANSI.yellow },
|
||||||
|
fail: { txt: 'FAIL', col: ANSI.red },
|
||||||
|
};
|
||||||
|
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
|
||||||
|
return colorize(`[${m.txt}]`, m.col, useColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
|
||||||
|
|
||||||
|
export function parseArgv(argv) {
|
||||||
|
const positional = [];
|
||||||
|
const flags = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
const eq = a.indexOf('=');
|
||||||
|
if (eq > 0) {
|
||||||
|
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
||||||
|
} else {
|
||||||
|
const name = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next !== undefined && !next.startsWith('--')) {
|
||||||
|
flags[name] = next;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
flags[name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
positional.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { positional, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Output helpers (respect --json) ───────────────────────────────────────
|
||||||
|
|
||||||
|
function makeIO(opts) {
|
||||||
|
const out = opts.out ?? (s => process.stdout.write(s));
|
||||||
|
const err = opts.err ?? (s => process.stderr.write(s));
|
||||||
|
const wantJson = opts.json === true;
|
||||||
|
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
|
||||||
|
// (debug noise suppression per the bundle requirements). `errln` always writes
|
||||||
|
// to stderr.
|
||||||
|
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
|
||||||
|
const errln = (...parts) => err(parts.join(' ') + '\n');
|
||||||
|
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
|
||||||
|
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let done = false;
|
||||||
|
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||||
|
let urlObj;
|
||||||
|
try { urlObj = new URL(url); }
|
||||||
|
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
|
||||||
|
const isHttps = urlObj.protocol === 'https:';
|
||||||
|
const reqFn = isHttps ? httpsRequest : httpRequest;
|
||||||
|
let req;
|
||||||
|
try {
|
||||||
|
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => { data += c; });
|
||||||
|
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return finish({ ok: false, error: String(e?.message ?? e) });
|
||||||
|
}
|
||||||
|
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Bearer token. Returns the plaintext token string or null.
|
||||||
|
* Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed).
|
||||||
|
*/
|
||||||
|
export function resolveBearerToken() {
|
||||||
|
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
|
||||||
|
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const tok = resolveBearerToken();
|
||||||
|
return tok ? { Authorization: `Bearer ${tok}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP-error → exit code mapping ────────────────────────────────────────
|
||||||
|
|
||||||
|
function httpErrorToExit(res, io) {
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
|
||||||
|
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
|
||||||
|
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
io.errln(`Error: network error: ${res.error}`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
|
||||||
|
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (res.status >= 400) {
|
||||||
|
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Human-readable formatters ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||||||
|
if (n < 1024) return `${n}B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
|
||||||
|
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms) {
|
||||||
|
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
||||||
|
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcommand: status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdStatus(flags, io) {
|
||||||
|
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||||
|
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
|
||||||
|
const ec = httpErrorToExit(res, io);
|
||||||
|
if (ec !== 0) return ec;
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(res.body); }
|
||||||
|
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||||
|
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||||
|
io.log(colorize('OLP status', ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
io.log(` version: ${body.version}`);
|
||||||
|
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
|
||||||
|
io.log(` started: ${body.started_at}`);
|
||||||
|
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
|
||||||
|
if (body.providers?.status && typeof body.providers.status === 'object') {
|
||||||
|
for (const [name, s] of Object.entries(body.providers.status)) {
|
||||||
|
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
|
||||||
|
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
|
||||||
|
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
|
||||||
|
if (body.stats?.cache) {
|
||||||
|
const c = body.stats.cache;
|
||||||
|
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||||
|
io.log(` recent errors: ${body.recent_errors.length}`);
|
||||||
|
for (const e of body.recent_errors.slice(0, 5)) {
|
||||||
|
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'} — ${(e.message ?? '').slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcommand: health ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdHealth(flags, io) {
|
||||||
|
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||||
|
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
|
||||||
|
const ec = httpErrorToExit(res, io);
|
||||||
|
if (ec !== 0) return ec;
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(res.body); }
|
||||||
|
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||||
|
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||||
|
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (typeof v === 'object' && v !== null) {
|
||||||
|
io.log(` ${k}:`);
|
||||||
|
for (const [k2, v2] of Object.entries(v)) {
|
||||||
|
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
io.log(` ${k}: ${v}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcommand: usage ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdUsage(flags, io) {
|
||||||
|
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||||
|
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
|
||||||
|
const ec = httpErrorToExit(res, io);
|
||||||
|
if (ec !== 0) return ec;
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(res.body); }
|
||||||
|
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||||
|
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||||
|
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
const u24 = body.usage_24h ?? body.usage24h ?? body['24h'] ?? {};
|
||||||
|
if (typeof u24 === 'object' && Object.keys(u24).length > 0) {
|
||||||
|
io.log(` requests: ${u24.requests ?? '?'}`);
|
||||||
|
io.log(` cache hits: ${u24.cache_hits ?? '?'}`);
|
||||||
|
io.log(` fallbacks: ${u24.fallbacks ?? '?'}`);
|
||||||
|
} else {
|
||||||
|
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.providers)) {
|
||||||
|
io.log('');
|
||||||
|
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
for (const p of body.providers) {
|
||||||
|
io.log(` ${String(p.name ?? '?').padEnd(12)} ${p.percent_used != null ? `${p.percent_used}% used` : 'no quota api'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.top_fallback_chains)) {
|
||||||
|
io.log('');
|
||||||
|
io.log(colorize('Top fallback chains', ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
for (const f of body.top_fallback_chains.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; }
|
||||||
|
io.log(colorize('OLP cache', ANSI.bold, io.useColor));
|
||||||
|
io.log('─'.repeat(60));
|
||||||
|
io.log(` entries: ${body.entries ?? '?'}`);
|
||||||
|
io.log(` hits: ${body.hits ?? 0}`);
|
||||||
|
io.log(` misses: ${body.misses ?? 0}`);
|
||||||
|
io.log(` evictions:${body.evictions ?? 0}`);
|
||||||
|
io.log(` bytes: ${formatBytes(body.bytes ?? 0)} (max ${formatBytes(body.maxBytes ?? 0)})`);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const result = await runDoctor({
|
||||||
|
olpHome,
|
||||||
|
proxyUrl,
|
||||||
|
checkFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
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).
|
- 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.
|
- 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.
|
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
|
## 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)
|
### 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
|
### 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,340 @@
|
|||||||
|
# ADR 0011 — Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant)
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-05-26)
|
||||||
|
**Date:** 2026-05-26
|
||||||
|
**D-day:** D70 (lands alongside D68 `olp-connect` + D69 `/health.anonymousKey`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0010 § Phase 4 D68-D70 charter scoped a three-deliverable bundle:
|
||||||
|
|
||||||
|
- **D68** `olp-connect <ip>` — client-side bash script that auto-configures a
|
||||||
|
family member's machine to point at a remote OLP instance, including IDE
|
||||||
|
detection (Cline / Continue.dev / Cursor / Aider / OpenClaw) and rc-file +
|
||||||
|
system-level env var writes.
|
||||||
|
- **D69** `/health.anonymousKey` field — opt-in (`auth.advertise_anonymous_key:
|
||||||
|
true` in `~/.olp/config.json`; default `false`) surface that emits the
|
||||||
|
plaintext of a designated guest-tier key so `olp-connect` can pick it up
|
||||||
|
with zero-config (no out-of-band token paste).
|
||||||
|
- **D70** this ADR — codifies the deployment-context limits that make D69
|
||||||
|
safe.
|
||||||
|
|
||||||
|
The D69 mechanism is a deliberate port of OCP's clever `PROXY_ANONYMOUS_KEY` +
|
||||||
|
`/health.anonymousKey` pattern (OCP `server.mjs:148, 1454, 1488, 1555`,
|
||||||
|
shipped 2026-04 under OCP issue #12 § 14 Path A) which made family-member
|
||||||
|
onboarding go from "maintainer texts API key + edits IDE config" to
|
||||||
|
`curl -fsSL .../ocp-connect | bash -s -- <ip>`. The OCP pattern works on
|
||||||
|
trusted family LAN deployments; it would catastrophically fail on a public
|
||||||
|
internet deployment. ADR 0011 makes the trust assumption explicit before OLP
|
||||||
|
inherits the pattern.
|
||||||
|
|
||||||
|
The ADR also pins three implementation details that are NOT obvious from
|
||||||
|
reading the D69 patch alone:
|
||||||
|
|
||||||
|
1. The plaintext token must live on disk SOMEWHERE for the server to surface
|
||||||
|
it. ADR 0007 § 5 + § 6.2 explicitly forbid plaintext storage in the
|
||||||
|
default manifest. D69 introduces an explicit **opt-in** `plaintext_advertise`
|
||||||
|
manifest field for ONLY the advertised key — every other key retains the
|
||||||
|
ADR 0007 § 5 hash-only contract.
|
||||||
|
2. The advertised key is **guest-tier**, not owner-tier. Owner-tier
|
||||||
|
advertisement is rejected at keygen time AND at config-load time —
|
||||||
|
exposing the owner identity unauthenticated would grant any LAN caller
|
||||||
|
`/health` full payload, `/v0/management/*` mutating access, and
|
||||||
|
`X-OLP-Fallback-Detail` visibility — the exact inverse of the advertise
|
||||||
|
key's intent (a low-privilege zero-config tier).
|
||||||
|
3. Three prerequisites MUST hold simultaneously for `/health.anonymousKey`
|
||||||
|
to be emitted; missing any one is logged at startup but the server still
|
||||||
|
boots — graceful-degrade rather than refuse-to-start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Three-prerequisite gate (server-side)
|
||||||
|
|
||||||
|
`/health` emits the `anonymousKey` field if and only if ALL THREE hold:
|
||||||
|
|
||||||
|
1. `auth.advertise_anonymous_key === true` in `~/.olp/config.json` (default
|
||||||
|
`false` — opt-in).
|
||||||
|
2. `auth.allow_anonymous === true` (the anonymous tier must be reachable for
|
||||||
|
the advertised key to be meaningful to zero-config callers; advertising a
|
||||||
|
key into a deployment that rejects anonymous requests is incoherent).
|
||||||
|
3. At least one active (`revoked_at === null`) manifest under `~/.olp/keys/`
|
||||||
|
carries a non-empty `plaintext_advertise: "olp_..."` field.
|
||||||
|
|
||||||
|
When prerequisite (1) holds but (2) or (3) fails, the server logs a
|
||||||
|
startup warn (`anonymous_key_advertised_but_denied` or
|
||||||
|
`anonymous_key_advertised_but_no_anonymous_key_exists`) but starts normally
|
||||||
|
and simply omits the field from `/health` responses.
|
||||||
|
|
||||||
|
### Plaintext storage mechanism (`plaintext_advertise` manifest field)
|
||||||
|
|
||||||
|
ADR 0007 § 5 forbids plaintext storage anywhere. D69 introduces a single,
|
||||||
|
**explicitly opt-in** exception: the manifest of the designated advertised
|
||||||
|
key gains a `plaintext_advertise` field whose value is the plaintext token.
|
||||||
|
|
||||||
|
This field is written ONLY when the operator runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
olp-keys keygen --anonymous --advertise
|
||||||
|
```
|
||||||
|
|
||||||
|
(or `--advertise` alone on a guest-tier `keygen` invocation; `--anonymous`
|
||||||
|
is a friendly shorthand for `--tier=guest --name=anonymous`). The keygen
|
||||||
|
command surfaces an explicit `WARNING` to stderr at creation time:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING: this key's plaintext is now stored on disk + will be exposed via
|
||||||
|
/health.anonymousKey when auth.advertise_anonymous_key=true AND
|
||||||
|
auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.
|
||||||
|
```
|
||||||
|
|
||||||
|
Every other key (every existing key, and every newly-created key without
|
||||||
|
`--advertise`) retains the ADR 0007 § 5 hash-only contract — `manifest.json`
|
||||||
|
contains `token_hash` and NEVER `plaintext_advertise`.
|
||||||
|
|
||||||
|
**Schema-version note (D69 reviewer P2-2).** This adds a new optional field
|
||||||
|
to the manifest. Per ADR 0007 § 4 ("Increment `schema_version` on any
|
||||||
|
non-additive change"), additive optional fields do NOT require a
|
||||||
|
`schema_version` bump — older parsers ignore unknown fields per the same
|
||||||
|
section's forward-compat rule. The manifest stays at `schema_version: 1`.
|
||||||
|
Documented here so a future archaeologist asking "why didn't D69 bump
|
||||||
|
`schema_version`?" has a one-line answer.
|
||||||
|
|
||||||
|
**`listKeys()` redaction (D69 reviewer P2-1).** `lib/keys.mjs listKeys()`
|
||||||
|
strips BOTH `token_hash` AND `plaintext_advertise` from its return value.
|
||||||
|
Callers wanting the advertised plaintext for the `/health` publication
|
||||||
|
path MUST go through `findAdvertisedKey()` — the only sanctioned read
|
||||||
|
site. This protects against a future caller of `listKeys()` accidentally
|
||||||
|
emitting the plaintext into logs / HTTP responses / dashboards.
|
||||||
|
|
||||||
|
### Tier restriction (guest only)
|
||||||
|
|
||||||
|
`createKey()` rejects `plaintext_advertise: true` for `owner_tier: 'owner'`
|
||||||
|
with the error `createKey: plaintext_advertise requires owner_tier="guest"`.
|
||||||
|
The CLI also rejects `--owner --advertise` with a clear error pointing at
|
||||||
|
this ADR.
|
||||||
|
|
||||||
|
Rationale: owner-tier confers `/health` full payload visibility,
|
||||||
|
`/v0/management/*` mutating access, and `X-OLP-Fallback-Detail` header
|
||||||
|
visibility. Advertising owner-tier plaintext unauthenticated would let any
|
||||||
|
LAN caller assume the owner identity — the exact opposite of the design
|
||||||
|
intent.
|
||||||
|
|
||||||
|
### Trusted-LAN deployment invariant
|
||||||
|
|
||||||
|
`auth.advertise_anonymous_key: true` is permitted ONLY when the OLP server
|
||||||
|
is bound to a trust-equivalent address space:
|
||||||
|
|
||||||
|
| Tier | Address space | Permitted? |
|
||||||
|
|------|---------------|------------|
|
||||||
|
| Loopback | `127.0.0.0/8` | yes |
|
||||||
|
| RFC 1918 LAN | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | yes |
|
||||||
|
| Tailnet | `100.64.0.0/10` (CGNAT range used by Tailscale) | yes |
|
||||||
|
| Localhost domains | `localhost`, `*.local`, `*.internal` | yes |
|
||||||
|
| Public internet | any routable IPv4/IPv6 outside the above | NO |
|
||||||
|
|
||||||
|
This is a **soft constraint** at v0.4.0 — OLP does not enforce IP-allowlist
|
||||||
|
or BIND_ADDRESS inspection. The constraint is documented here, surfaced in
|
||||||
|
README § "Anonymous-key advertise mode (trusted-LAN-only)", and warned-but-
|
||||||
|
not-blocked at server startup when `auth.advertise_anonymous_key=true` and
|
||||||
|
the bind address looks public.
|
||||||
|
|
||||||
|
Hard enforcement (refuse to start when bind is public + advertise enabled)
|
||||||
|
is deferred. The maintainer's deployments are LAN-only, the family-scale
|
||||||
|
audience cannot tolerate a startup-refuse mode that bricks the proxy on
|
||||||
|
ambiguous network topology (e.g., TLS-fronted private network where the
|
||||||
|
underlying bind IP IS public but the network itself is trusted), and the
|
||||||
|
trade-off in ADR 0010 explicitly accepted operator-discretion gates for
|
||||||
|
soft constraints of this class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The advertised anonymous key is **public** within the boundary of "anyone
|
||||||
|
who can reach `GET /health`." Anyone within that boundary can read the
|
||||||
|
plaintext from `/health.anonymousKey` and use it for `/v1/chat/completions`,
|
||||||
|
`/v1/models`, etc.
|
||||||
|
|
||||||
|
| Deployment | Boundary | Acceptable? |
|
||||||
|
|------------|----------|-------------|
|
||||||
|
| Mac mini + Tailscale, only family devices on tailnet | family devices | YES |
|
||||||
|
| Home LAN with no guest WiFi, no port-forward | household + neighbors-within-WiFi-range | YES (within risk tolerance) |
|
||||||
|
| Home LAN with guest WiFi joined to same VLAN as proxy | EVERYONE who visits and connects to guest WiFi | borderline; treat with caution |
|
||||||
|
| Coffee shop / open WiFi | EVERYONE physically present | NO |
|
||||||
|
| Public internet via Cloudflare Tunnel / port-forward / VPS | EVERYONE on the internet | NO — instant compromise |
|
||||||
|
|
||||||
|
The capability gain for an attacker who reads `/health.anonymousKey` is
|
||||||
|
**equal to the capability the operator deliberately granted the
|
||||||
|
anonymous-tier key**:
|
||||||
|
|
||||||
|
- `providers_enabled` (when `'*'`, the attacker can dispatch any provider —
|
||||||
|
burning the operator's subscription quotas).
|
||||||
|
- `/v1/chat/completions` access (LLM use under the operator's billing).
|
||||||
|
- Cache pollution under `__anonymous__` namespace (per ADR 0007 § 7.1; the
|
||||||
|
advertised guest key uses its own `<key-id>` namespace — but anonymous
|
||||||
|
callers who DON'T present the key use `__anonymous__`).
|
||||||
|
|
||||||
|
What the attacker does NOT get:
|
||||||
|
|
||||||
|
- `/health` full payload — gated to `owner_tier === 'owner'` (ADR 0007 § 7.1).
|
||||||
|
- `/v0/management/*` mutating endpoints — gated to owner (ADR 0008 § 7).
|
||||||
|
- `X-OLP-Fallback-Detail` header — `'owner_only'` policy default (ADR 0007 § 7.2).
|
||||||
|
- The owner key's plaintext (which is never stored anywhere; only its
|
||||||
|
`token_hash` is on disk per ADR 0007 § 5).
|
||||||
|
|
||||||
|
The "burn the operator's subscription quotas" failure mode is bounded by
|
||||||
|
the per-provider quota limits AND the maintainer's monitoring (`/health`
|
||||||
|
owner-view shows quota status per provider; `/v0/management/audit` shows
|
||||||
|
per-key usage). Detection is fast; the question is how much quota the
|
||||||
|
attacker can burn between compromise and key revocation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `olp-connect` integration (D68 client-side)
|
||||||
|
|
||||||
|
`bin/olp-connect <ip>` queries `GET /health` as its first action. If the
|
||||||
|
response contains `anonymousKey: "olp_..."`, the script uses that value
|
||||||
|
silently for the rest of the run (printing a one-line `Using server-
|
||||||
|
advertised anonymous key: olp_...XXXX` notice + a pointer to this ADR).
|
||||||
|
This is what makes `olp-connect <ip>` a true zero-config command — no
|
||||||
|
out-of-band token paste needed.
|
||||||
|
|
||||||
|
If `anonymousKey` is absent (the default, when `auth.advertise_anonymous_key`
|
||||||
|
is false), the script falls back to interactive prompt or `--key` flag.
|
||||||
|
|
||||||
|
`olp-connect` does NOT perform any of the trusted-LAN soft-checks itself —
|
||||||
|
it trusts that an operator who set `auth.advertise_anonymous_key: true`
|
||||||
|
knows their deployment context. The script does, however, document the
|
||||||
|
trade-off in its `--help` output and prints the ADR 0011 reference
|
||||||
|
alongside the "using server-advertised key" notice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Re-evaluation triggers
|
||||||
|
|
||||||
|
Re-open this ADR when ANY of the following fires:
|
||||||
|
|
||||||
|
1. OLP gains a "expose to public internet" deployment mode in the README
|
||||||
|
(e.g., Cloudflare Tunnel guidance, ngrok recipe). At that point the
|
||||||
|
soft-constraint MUST become a hard constraint (bind-address inspection
|
||||||
|
at startup, refusal to enable `advertise_anonymous_key` when bind is
|
||||||
|
public — likely with a separate `OLP_TRUSTED_PUBLIC_OVERRIDE=1` env
|
||||||
|
escape hatch for operators who run their own TLS termination).
|
||||||
|
2. The OCP `/health.anonymousKey` model is found to have caused a
|
||||||
|
real-world quota-burn incident; that learning amends this ADR.
|
||||||
|
3. Phase 5 introduces multi-tenant SaaS-like deployments (currently
|
||||||
|
non-goal per ADR 0001); the entire family-scale assumption is
|
||||||
|
re-examined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.**
|
||||||
|
|
||||||
|
- Family-member onboarding becomes a single command: `olp-connect <ip>`. No
|
||||||
|
out-of-band token paste. No "wait, what's the API key?" friction loop.
|
||||||
|
- The trust trade-off is now an explicit, single-knob config decision, not
|
||||||
|
an implicit consequence of OCP-pattern inheritance.
|
||||||
|
- The `plaintext_advertise` field is a single auditable on-disk surface —
|
||||||
|
`grep plaintext_advertise ~/.olp/keys/*/manifest.json` answers "which key
|
||||||
|
is advertised?" definitively, and an operator who wants to disable the
|
||||||
|
feature can simply revoke that key.
|
||||||
|
- Owner-tier advertisement is impossible (both at keygen and at config
|
||||||
|
load), eliminating an entire class of foot-gun.
|
||||||
|
|
||||||
|
**Negative.**
|
||||||
|
|
||||||
|
- ADR 0007 § 5's "no plaintext on disk, ever" property is weakened to "no
|
||||||
|
plaintext on disk except for ONE explicitly-opted-in field on ONE key."
|
||||||
|
The exception is narrow and audit-grep-able but the property is no
|
||||||
|
longer absolute.
|
||||||
|
- Operators who enable advertise mode then move the deployment from LAN to
|
||||||
|
public internet (e.g., add a Cloudflare Tunnel without revisiting the
|
||||||
|
config) silently invert the threat model. The startup warn for "public
|
||||||
|
bind detected" does not currently fire (soft constraint per § "Trusted-
|
||||||
|
LAN deployment invariant" above).
|
||||||
|
- The OCP precedent shows operators sometimes share `olp-connect <ip>`
|
||||||
|
invocations in chat / docs that include their IP; an LLM training corpus
|
||||||
|
could harvest these IPs. The advertised key is only useful while the
|
||||||
|
network reaches the IP, but the IP-disclosure surface grows.
|
||||||
|
|
||||||
|
**Neutral.**
|
||||||
|
|
||||||
|
- The plaintext storage is per-key, not global. Revoking the advertised key
|
||||||
|
removes the plaintext exposure within one filesystem write (the manifest
|
||||||
|
stays on disk for audit attribution per ADR 0007 § 6.1, but `revoked_at`
|
||||||
|
becomes non-null and `findAdvertisedKey()` skips revoked manifests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
1. **Store plaintext in `config.json` directly.** Rejected. Mixes secrets
|
||||||
|
with operational config; complicates git-crypt boundary; loses the
|
||||||
|
per-key revocation path (you'd have to edit JSON to "revoke" the
|
||||||
|
exposure rather than running `olp-keys revoke --id=<id>`).
|
||||||
|
2. **Add an `anonymous` owner_tier instead of using `guest` + `plaintext_
|
||||||
|
advertise`.** Rejected. Bumps ADR 0007 § 4 schema version (a
|
||||||
|
non-additive change), adds a third identity class that the rest of the
|
||||||
|
codebase (cache namespacing, /health gating, audit attribution) has no
|
||||||
|
reason to know about, and conflicts with ADR 0007 § 7.1's "anonymous =
|
||||||
|
no auth header + allow_anonymous=true" definition. A single optional
|
||||||
|
field on the manifest is strictly less invasive.
|
||||||
|
3. **Hard-enforce trusted-LAN bind address at startup.** Rejected for
|
||||||
|
v0.4.0; deferred until a public-deployment-mode README section ships
|
||||||
|
(see Re-evaluation triggers § 1). Soft constraint + startup warn is
|
||||||
|
appropriate while OLP has zero public-internet deployment recipes.
|
||||||
|
4. **Encrypt `plaintext_advertise` at rest with a key derived from
|
||||||
|
`OLP_HOME` path or a separate `OLP_ADVERTISE_KEY` env var.** Rejected.
|
||||||
|
The threat model is "anyone who can read `/health` reads the plaintext
|
||||||
|
token over the wire," not "anyone who can read `~/.olp/keys/`." Both
|
||||||
|
require LAN-reach; encrypting on-disk doesn't change the over-the-wire
|
||||||
|
exposure. Adds complexity for no security gain in the relevant attack
|
||||||
|
model.
|
||||||
|
5. **Make `--advertise` allowed only when `--name` is exactly `anonymous`.**
|
||||||
|
Rejected as over-restrictive. The CLI's `--anonymous` shorthand
|
||||||
|
defaults `--name=anonymous`, but operators may legitimately want a
|
||||||
|
named advertised key (e.g., `family-guest`, `lan-zero-config`). The
|
||||||
|
discriminator is the field, not the name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authority citations
|
||||||
|
|
||||||
|
- **ADR 0007 § 7** (Identity-class table — anonymous tier definition;
|
||||||
|
`__anonymous__` keyId).
|
||||||
|
- **ADR 0007 § 5** (Token format — establishes hash-only on-disk; D69 is
|
||||||
|
the explicit opt-in exception).
|
||||||
|
- **ADR 0007 § 4** (Manifest schema — D69 adds optional `plaintext_advertise`
|
||||||
|
field; § 4 already specifies "unrecognized fields cause a warn but not a
|
||||||
|
reject (forward-compat)" so the addition is non-breaking for older
|
||||||
|
parsers).
|
||||||
|
- **ADR 0007 § 7.2** (Configuration — D69 adds `auth.advertise_anonymous_key`
|
||||||
|
alongside existing `allow_anonymous` / `owner_only_endpoints` /
|
||||||
|
`fallback_detail_header_policy`).
|
||||||
|
- **ADR 0010 § Phase 4 charter D68-D70 row** (scope authority for this ADR).
|
||||||
|
- **OCP `server.mjs:148, 1454, 1488, 1555`** (prior-art for the
|
||||||
|
`PROXY_ANONYMOUS_KEY` env + `/health.anonymousKey` pattern; OCP v3.13.0).
|
||||||
|
- **OCP issue #12 § 14 Path A** (the original anonymous-key decision
|
||||||
|
context for OCP; the "Path A" label is OCP-specific and not used in
|
||||||
|
OLP).
|
||||||
|
- **`bin/olp-connect`** (D68 client-side consumer of `/health.anonymousKey`).
|
||||||
|
- **`bin/olp-keys.mjs`** (D69 keygen `--advertise` flag implementation).
|
||||||
|
- **`lib/keys.mjs` `findAdvertisedKey()`** (D69 server-side resolver).
|
||||||
|
- **`server.mjs handleHealth`** (D69 emission point + startup-warn site).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedural mechanism
|
||||||
|
|
||||||
|
CC 开发铁律 v1.6 § 10 (independent reviewer per implementation D-day) — D68
|
||||||
|
+ D69 + D70 ship as ONE PR per Iron Rule 11 IDR (the three deliverables
|
||||||
|
are mutually constituting: `olp-connect` consumes `/health.anonymousKey`,
|
||||||
|
`/health.anonymousKey` is governed by ADR 0011, ADR 0011 documents
|
||||||
|
`olp-connect`'s trust posture). The reviewer is a fresh-context opus
|
||||||
|
subagent.
|
||||||
@@ -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. |
|
| [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). |
|
| [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. |
|
| [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
|
## 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).
|
||||||
+576
@@ -0,0 +1,576 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
||||||
|
if (!r.ok) {
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
message: `${proxyUrl}/health unreachable: ${r.error}`,
|
||||||
|
evidence: {
|
||||||
|
fix_commands: [
|
||||||
|
'npx olp restart',
|
||||||
|
],
|
||||||
|
reference: 'README.md § Running OLP',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
||||||
|
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
|
* Returns empty config (no chains, no soft triggers, no enabled providers) if the
|
||||||
* file is absent, unreadable, or malformed.
|
* 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)
|
* @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) {
|
export function loadFallbackConfigSync(configPath) {
|
||||||
|
const DEFAULT_STREAMING = { heartbeat_interval_ms: 0 };
|
||||||
try {
|
try {
|
||||||
const path = configPath ?? defaultConfigPath();
|
const path = configPath ?? defaultConfigPath();
|
||||||
const raw = readFileSync(path, 'utf8');
|
const raw = readFileSync(path, 'utf8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const routing = parsed?.routing ?? {};
|
const routing = parsed?.routing ?? {};
|
||||||
const providers = parsed?.providers ?? {};
|
const providers = parsed?.providers ?? {};
|
||||||
|
const streaming = parsed?.streaming ?? {};
|
||||||
|
const hb = Number(streaming.heartbeat_interval_ms);
|
||||||
return {
|
return {
|
||||||
chains: routing.chains ?? {},
|
chains: routing.chains ?? {},
|
||||||
soft_triggers: routing.soft_triggers ?? {},
|
soft_triggers: routing.soft_triggers ?? {},
|
||||||
providersEnabled: providers.enabled ?? {},
|
providersEnabled: providers.enabled ?? {},
|
||||||
|
streaming: {
|
||||||
|
heartbeat_interval_ms: Number.isFinite(hb) && hb >= 0 ? hb : 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
|
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
|
||||||
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
|
// 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.
|
* never logged. The manifest contains only the hash.
|
||||||
*/
|
*/
|
||||||
export function createKey(args = {}) {
|
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) {
|
if (typeof name !== 'string' || name.length === 0) {
|
||||||
throw new Error('createKey: name is required (non-empty string)');
|
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))) {
|
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
|
||||||
throw new Error('createKey: providers_enabled must be "*" or string array');
|
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 id = generateKeyId();
|
||||||
const plaintext_token = generateToken();
|
const plaintext_token = generateToken();
|
||||||
@@ -276,10 +285,59 @@ export function createKey(args = {}) {
|
|||||||
last_used_at: null,
|
last_used_at: null,
|
||||||
notes,
|
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 });
|
writeManifestAtomic(id, manifest, { olpHome });
|
||||||
return { id, plaintext_token, manifest };
|
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
|
* List all keys. Returns array of manifest objects with `token_hash` redacted
|
||||||
* (kept on disk; omitted from list output per common operational hygiene —
|
* (kept on disk; omitted from list output per common operational hygiene —
|
||||||
@@ -299,8 +357,14 @@ export function listKeys(opts = {}) {
|
|||||||
try {
|
try {
|
||||||
const m = readManifest(id, opts);
|
const m = readManifest(id, opts);
|
||||||
if (m === null) continue;
|
if (m === null) continue;
|
||||||
// Redact token_hash from list output (keep on disk).
|
// D69 reviewer P2-1 (footgun-removal): strip BOTH `token_hash` AND
|
||||||
const { token_hash, ...rest } = m;
|
// `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);
|
out.push(rest);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip invalid manifest; production impl would log warn.
|
// 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)
|
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
||||||
* - fallback_detail_header_policy: 'owner_only' (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
|
* Returns the auth config object. Never throws — missing file / parse
|
||||||
* error / missing `auth` key all fall back to defaults.
|
* error / missing `auth` key all fall back to defaults.
|
||||||
*
|
*
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
* @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 = {}) {
|
export function loadAuthConfigSync(opts = {}) {
|
||||||
const olpHome = _resolveOlpHome(opts);
|
const olpHome = _resolveOlpHome(opts);
|
||||||
@@ -470,6 +539,7 @@ export function loadAuthConfigSync(opts = {}) {
|
|||||||
allow_anonymous: false,
|
allow_anonymous: false,
|
||||||
owner_only_endpoints: ['/health'],
|
owner_only_endpoints: ['/health'],
|
||||||
fallback_detail_header_policy: 'owner_only',
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
advertise_anonymous_key: false,
|
||||||
};
|
};
|
||||||
if (!existsSync(path)) return { ...DEFAULTS };
|
if (!existsSync(path)) return { ...DEFAULTS };
|
||||||
try {
|
try {
|
||||||
@@ -484,6 +554,7 @@ export function loadAuthConfigSync(opts = {}) {
|
|||||||
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
||||||
? auth.fallback_detail_header_policy
|
? auth.fallback_detail_header_policy
|
||||||
: DEFAULTS.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 {
|
} catch {
|
||||||
// Malformed JSON / unreadable file → safe defaults
|
// 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 ───────────────────────────────────────────────────────
|
// ── Provider export ───────────────────────────────────────────────────────
|
||||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
|
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
|
||||||
// contractVersion fold-in per reviewer F3.
|
// contractVersion fold-in per reviewer F3.
|
||||||
@@ -509,6 +567,8 @@ const anthropic = {
|
|||||||
estimateCost,
|
estimateCost,
|
||||||
quotaStatus,
|
quotaStatus,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
|
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||||
|
doctorChecks: () => doctorChecks(),
|
||||||
hints: {
|
hints: {
|
||||||
requiresTTY: false,
|
requiresTTY: false,
|
||||||
concurrentSpawnSafe: true,
|
concurrentSpawnSafe: true,
|
||||||
|
|||||||
@@ -30,6 +30,13 @@
|
|||||||
* collectAllChunks directly. ADR 0002 Amendment 3 (D23).
|
* 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
|
* @typedef {Object} ProviderContractV1
|
||||||
* @property {string} name - unique lowercase key
|
* @property {string} name - unique lowercase key
|
||||||
@@ -41,6 +48,7 @@
|
|||||||
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
|
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
|
||||||
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
|
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
|
||||||
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
|
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
|
||||||
|
* @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67)
|
||||||
* @property {ProviderHints} hints
|
* @property {ProviderHints} hints
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -113,6 +121,13 @@ export function validateProvider(p) {
|
|||||||
errors.push('healthCheck must be a function');
|
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') {
|
if (!p.hints || typeof p.hints !== 'object') {
|
||||||
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
|
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -637,6 +637,59 @@ function _defaultBinaryExists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
|
||||||
|
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
|
||||||
|
//
|
||||||
|
// Probes:
|
||||||
|
// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN)
|
||||||
|
// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken
|
||||||
|
// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json)
|
||||||
|
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||||
|
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
|
||||||
|
const authRead = _authReadFn ?? readAuthArtifact;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'openai.cli_available',
|
||||||
|
category: 'provider',
|
||||||
|
async run() {
|
||||||
|
if (binaryExists()) {
|
||||||
|
return { status: 'ok', message: '`codex` binary resolved on PATH' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)',
|
||||||
|
evidence: {
|
||||||
|
fix_commands: [
|
||||||
|
'npm install -g @openai/codex',
|
||||||
|
],
|
||||||
|
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai.auth_present',
|
||||||
|
category: 'provider',
|
||||||
|
async run() {
|
||||||
|
const auth = authRead();
|
||||||
|
if (auth?.accessToken) {
|
||||||
|
return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)',
|
||||||
|
evidence: {
|
||||||
|
human_steps: [
|
||||||
|
'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)',
|
||||||
|
],
|
||||||
|
reference: 'https://developers.openai.com/codex/cli/reference',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider export ───────────────────────────────────────────────────────
|
// ── Provider export ───────────────────────────────────────────────────────
|
||||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||||
|
|
||||||
@@ -662,6 +715,8 @@ const codex = {
|
|||||||
estimateCost,
|
estimateCost,
|
||||||
quotaStatus,
|
quotaStatus,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
|
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||||
|
doctorChecks: () => doctorChecks(),
|
||||||
hints: {
|
hints: {
|
||||||
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
||||||
concurrentSpawnSafe: true, // each invocation is independent
|
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 ───────────────────────────────────────────────────────
|
// ── Provider export ───────────────────────────────────────────────────────
|
||||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||||
|
|
||||||
@@ -795,6 +849,8 @@ const mistral = {
|
|||||||
estimateCost,
|
estimateCost,
|
||||||
quotaStatus,
|
quotaStatus,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
|
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
|
||||||
|
doctorChecks: () => doctorChecks(),
|
||||||
hints: {
|
hints: {
|
||||||
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
||||||
concurrentSpawnSafe: true, // each invocation is independent
|
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,437 @@
|
|||||||
|
/**
|
||||||
|
* 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`;
|
||||||
|
}
|
||||||
|
if (body.providers && typeof body.providers === "object") {
|
||||||
|
out += `\nProviders:\n`;
|
||||||
|
for (const [name, s] of Object.entries(body.providers)) {
|
||||||
|
if (typeof s !== "object" || s === null) continue;
|
||||||
|
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||||
|
out += ` ${i} ${name}\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",
|
"name": "olp",
|
||||||
"version": "0.3.2",
|
"version": "0.4.0",
|
||||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server.mjs",
|
"main": "server.mjs",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"olp": "./bin/olp.mjs",
|
||||||
"olp-keys": "./bin/olp-keys.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": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node test-features.mjs",
|
"test": "node test-features.mjs",
|
||||||
|
"olp": "node bin/olp.mjs",
|
||||||
"olp-keys": "node bin/olp-keys.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": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|||||||
+454
-19
@@ -14,7 +14,10 @@
|
|||||||
* - Zero runtime npm dependencies in the proxy core
|
* - Zero runtime npm dependencies in the proxy core
|
||||||
*
|
*
|
||||||
* Env vars:
|
* Env vars:
|
||||||
* OLP_PORT — listen port (default: 3456)
|
* OLP_PORT — listen port (default: 4567 since D60 / v0.4.0 — moved off 3456
|
||||||
|
* so OLP can co-host with OCP for migration windows. ADR 0010 §
|
||||||
|
* Default port. Set OLP_PORT=3456 explicitly to restore the
|
||||||
|
* pre-D60 default when not co-hosting with OCP.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
@@ -49,10 +52,12 @@ import {
|
|||||||
loadFallbackConfigSync,
|
loadFallbackConfigSync,
|
||||||
} from './lib/fallback/engine.mjs';
|
} from './lib/fallback/engine.mjs';
|
||||||
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
||||||
|
// D69 (ADR 0011): findAdvertisedKey for /health.anonymousKey opt-in surface.
|
||||||
import {
|
import {
|
||||||
validateKey,
|
validateKey,
|
||||||
touchLastUsed,
|
touchLastUsed,
|
||||||
loadAuthConfigSync,
|
loadAuthConfigSync,
|
||||||
|
findAdvertisedKey,
|
||||||
ANONYMOUS_KEY_ID,
|
ANONYMOUS_KEY_ID,
|
||||||
ENV_OWNER_KEY_ID,
|
ENV_OWNER_KEY_ID,
|
||||||
} from './lib/keys.mjs';
|
} from './lib/keys.mjs';
|
||||||
@@ -71,7 +76,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
||||||
const VERSION = pkg.version;
|
const VERSION = pkg.version;
|
||||||
|
|
||||||
const PORT = parseInt(process.env.OLP_PORT ?? '3456', 10);
|
const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10);
|
||||||
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
// ── Logging ───────────────────────────────────────────────────────────────
|
// ── Logging ───────────────────────────────────────────────────────────────
|
||||||
@@ -87,6 +92,117 @@ function logEvent(level, event, data = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SSE response headers (D61, ADR 0010 § Phase 4 D61-D63) ────────────────
|
||||||
|
// Canonical SSE response-header set used by every streaming response. Pulled
|
||||||
|
// out as a top-level constant so all streaming write sites share a single
|
||||||
|
// source of truth (per OCP db11105 lesson: drift between branches caused the
|
||||||
|
// "headers sent but no Buffering directive" 502 regression).
|
||||||
|
//
|
||||||
|
// Authority: RFC 8895 (text/event-stream); nginx `X-Accel-Buffering: no`
|
||||||
|
// disables the upstream proxy buffer (load-bearing for real-time delivery
|
||||||
|
// behind nginx / Cloudflare / Tailscale Funnel).
|
||||||
|
const SSE_DEFAULT_HEADERS = Object.freeze({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── D62 recentErrors[20] ring buffer (ADR 0010 § Phase 4 D61-D63) ─────────
|
||||||
|
// Last 20 server-side error events. Surfaced via /status (owner-only). Ported
|
||||||
|
// from OCP server.mjs:301 + 354-358 — same shape adapted to OLP's identity-
|
||||||
|
// aware error vocabulary (key_id present when known; never client-tier-only
|
||||||
|
// errors like 401/403 which would let brute-force loops fill the ring).
|
||||||
|
//
|
||||||
|
// Lifecycle: module-scope, in-memory only. NOT persisted across restart
|
||||||
|
// (OCP precedent: same; acceptable per the spec).
|
||||||
|
//
|
||||||
|
// Sanitization: filesystem-path-like tokens in `message` are replaced with
|
||||||
|
// `[path]` so an unintentionally logged absolute path doesn't surface in a
|
||||||
|
// `/status` payload an owner copy-pastes elsewhere. Port of OCP server.mjs:1395.
|
||||||
|
//
|
||||||
|
// 200-char message cap: matches OCP precedent; keeps the ring bounded even
|
||||||
|
// for verbose stack traces (the dashboard / `olp` CLI surface a one-line
|
||||||
|
// preview, not the full trace).
|
||||||
|
const RECENT_ERRORS_MAX = 20;
|
||||||
|
const recentErrors = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a server-side error event onto the recentErrors ring. Filters out
|
||||||
|
* client-tier auth rejections (401/403) so a brute-force loop cannot flood
|
||||||
|
* the ring. ProviderError instances are always recorded; other errors are
|
||||||
|
* recorded when `statusCode >= 500`.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {Error|null|undefined} opts.error
|
||||||
|
* @param {string|null} [opts.provider]
|
||||||
|
* @param {string|null} [opts.path]
|
||||||
|
* @param {number|null} [opts.statusCode]
|
||||||
|
*/
|
||||||
|
function _pushError({ error, provider = null, path = null, statusCode = null }) {
|
||||||
|
// D61-D63 reviewer P2-1 (defense-in-depth): explicitly reject 401/403 at the
|
||||||
|
// function level even if a caller mistakenly passes one. The current call
|
||||||
|
// sites all use the "no auth" path → never invoke _pushError, but a future
|
||||||
|
// contributor passing a ProviderError tagged statusCode=401 would otherwise
|
||||||
|
// slip past the isProviderError branch and flood the ring under brute force.
|
||||||
|
if (statusCode === 401 || statusCode === 403) return;
|
||||||
|
// Filter: any other status is recorded when (a) statusCode >= 500 OR (b) the
|
||||||
|
// error is a ProviderError (provider-shape failure, always interesting for
|
||||||
|
// operators).
|
||||||
|
const isProviderError = error?.code === 'PROVIDER_ERROR'
|
||||||
|
|| error?.code === 'SPAWN_FAILED'
|
||||||
|
|| error?.code === 'CONCURRENCY_LIMIT'
|
||||||
|
|| error?.constructor?.name === 'ProviderError';
|
||||||
|
const looksServerSide = statusCode !== null && statusCode >= 500;
|
||||||
|
if (!isProviderError && !looksServerSide) return;
|
||||||
|
|
||||||
|
const rawMessage = String(error?.message ?? error ?? 'unknown error');
|
||||||
|
// Strip filesystem-path-like tokens. Port of OCP server.mjs:1395 — the
|
||||||
|
// regex catches `/foo/bar` plus dotted/dashed variants. Replaces every
|
||||||
|
// match with `[path]`. Truncate to 200 chars after sanitization.
|
||||||
|
const sanitized = rawMessage
|
||||||
|
.replace(/\/[\w./-]+/g, '[path]')
|
||||||
|
.slice(0, 200);
|
||||||
|
|
||||||
|
recentErrors.push({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: sanitized,
|
||||||
|
code: error?.code ?? null,
|
||||||
|
provider,
|
||||||
|
path,
|
||||||
|
status_code: statusCode,
|
||||||
|
});
|
||||||
|
while (recentErrors.length > RECENT_ERRORS_MAX) recentErrors.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — test seam: clear the recentErrors ring. */
|
||||||
|
export function __clearRecentErrors() {
|
||||||
|
recentErrors.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — test seam: snapshot the recentErrors ring (copy). */
|
||||||
|
export function __snapshotRecentErrors() {
|
||||||
|
return recentErrors.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── D63 request counters (ADR 0010 § Phase 4 D61-D63) ─────────────────────
|
||||||
|
// Module-scope counters surfaced via /status (owner-only). _totalRequests is
|
||||||
|
// the cumulative count of /v1/chat/completions invocations since process
|
||||||
|
// start; _activeRequests is the live concurrent count.
|
||||||
|
//
|
||||||
|
// NOT exposed via /health (already owner-trimmed per Phase 2 D46); only
|
||||||
|
// /status (owner-only_block) reads them. Ported from OCP `stats.totalRequests`
|
||||||
|
// + `stats.activeRequests` (server.mjs:288-300).
|
||||||
|
let _totalRequests = 0;
|
||||||
|
let _activeRequests = 0;
|
||||||
|
const _serverStartMs = Date.now();
|
||||||
|
|
||||||
|
/** @internal — test seam: reset request counters. */
|
||||||
|
export function __resetRequestCounters() {
|
||||||
|
_totalRequests = 0;
|
||||||
|
_activeRequests = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Startup config ────────────────────────────────────────────────────────
|
// ── Startup config ────────────────────────────────────────────────────────
|
||||||
// Read ~/.olp/config.json once at startup. Provides:
|
// Read ~/.olp/config.json once at startup. Provides:
|
||||||
// - providers.enabled → which providers are loaded (ADR 0002 § Disable model)
|
// - providers.enabled → which providers are loaded (ADR 0002 § Disable model)
|
||||||
@@ -117,10 +233,37 @@ if (_authConfig.allow_anonymous === true) {
|
|||||||
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// D69 (ADR 0011): startup-time prerequisite validation for /health.anonymousKey
|
||||||
|
// advertisement. The field is emitted ONLY when all three prerequisites hold:
|
||||||
|
// (1) auth.advertise_anonymous_key === true (config opt-in)
|
||||||
|
// (2) auth.allow_anonymous === true (anonymous tier must be reachable
|
||||||
|
// for the advertised key to be
|
||||||
|
// meaningful to zero-config callers)
|
||||||
|
// (3) at least one advertised key exists on disk (manifest with
|
||||||
|
// plaintext_advertise field, not revoked)
|
||||||
|
// Violations log warn at startup but the server still boots; the runtime
|
||||||
|
// handleHealth checks the same conditions and simply omits the field.
|
||||||
|
if (_authConfig.advertise_anonymous_key === true) {
|
||||||
|
if (_authConfig.allow_anonymous !== true) {
|
||||||
|
logEvent('warn', 'anonymous_key_advertised_but_denied', {
|
||||||
|
message: 'auth.advertise_anonymous_key=true but auth.allow_anonymous=false — the advertised key would not be usable anonymously. /health.anonymousKey will NOT be emitted until allow_anonymous=true. See ADR 0011.',
|
||||||
|
});
|
||||||
|
} else if (findAdvertisedKey() === null) {
|
||||||
|
logEvent('warn', 'anonymous_key_advertised_but_no_anonymous_key_exists', {
|
||||||
|
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||||
export function __setAuthConfig(config) {
|
export function __setAuthConfig(config) {
|
||||||
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
|
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only', advertise_anonymous_key: false };
|
||||||
|
// D69: defensively normalize so tests passing partial configs still get a
|
||||||
|
// boolean advertise_anonymous_key value (downstream code treats undefined
|
||||||
|
// as falsy but explicit normalisation matches loadAuthConfigSync's contract).
|
||||||
|
if (typeof _authConfig.advertise_anonymous_key !== 'boolean') {
|
||||||
|
_authConfig.advertise_anonymous_key = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal — reset auth config to file-loaded state. */
|
/** @internal — reset auth config to file-loaded state. */
|
||||||
@@ -128,6 +271,76 @@ export function __resetAuthConfig() {
|
|||||||
_authConfig = loadAuthConfigSync();
|
_authConfig = loadAuthConfigSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Streaming config (D61, ADR 0010 § Phase 4 D61-D63) ────────────────────
|
||||||
|
// Reads `streaming.heartbeat_interval_ms` from ~/.olp/config.json via the
|
||||||
|
// same loader used by routing/providers config. Default 0 = disabled;
|
||||||
|
// behaviour is opt-in. Tests inject synthetic configs via __setStreamingConfig.
|
||||||
|
let _streamingConfig = _startupConfig.streaming ?? { heartbeat_interval_ms: 0 };
|
||||||
|
|
||||||
|
/** @internal — test seam: inject a synthetic streaming config (no file I/O). */
|
||||||
|
export function __setStreamingConfig(config) {
|
||||||
|
_streamingConfig = config ?? { heartbeat_interval_ms: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — reset streaming config to file-loaded state. */
|
||||||
|
export function __resetStreamingConfig() {
|
||||||
|
_streamingConfig = loadFallbackConfigSync().streaming ?? { heartbeat_interval_ms: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE heartbeat (D61, ADR 0010 § Phase 4 D61-D63) ───────────────────────
|
||||||
|
// Port of OCP `startHeartbeat` (ocp/server.mjs:660-685). Emits `: keepalive\n\n`
|
||||||
|
// SSE comment frames on `res` every `intervalMs` ms during silent windows.
|
||||||
|
// The timer is reset on each real chunk write (`reset()`); cancelled on
|
||||||
|
// stream end / error / client disconnect (`stop()`).
|
||||||
|
//
|
||||||
|
// Discipline (load-bearing): this is a downstream liveness hint only — it
|
||||||
|
// MUST NOT abort or time out a request. The OCP timeout-tier regressions
|
||||||
|
// (v2.2-v2.5; see OCP spec) showed that any auto-cancellation primitive in
|
||||||
|
// the streaming branch is hostile to long-reasoning prompts.
|
||||||
|
//
|
||||||
|
// Per-attached-client: each call to startHeartbeat creates its own timer
|
||||||
|
// state. The streaming-singleflight `attached` client and the `source`
|
||||||
|
// client each get their own heartbeat instance (they share an underlying
|
||||||
|
// spawn but write to different `res` objects). The `res.on('close')` handler
|
||||||
|
// in the streaming branch calls `stop()` to cancel each client's timer
|
||||||
|
// independently.
|
||||||
|
//
|
||||||
|
// @param {import('node:http').ServerResponse} res
|
||||||
|
// @param {number} intervalMs — 0 or negative ⇒ no-op (timer never starts)
|
||||||
|
// @param {string} requestId — for the one-time `heartbeat_active` log event
|
||||||
|
// @returns {{ reset: () => void, stop: () => void }}
|
||||||
|
function startHeartbeat(res, intervalMs, requestId) {
|
||||||
|
if (!intervalMs || intervalMs <= 0) {
|
||||||
|
return { reset: () => {}, stop: () => {} };
|
||||||
|
}
|
||||||
|
let handle = null;
|
||||||
|
let hasFired = false;
|
||||||
|
const onFire = () => {
|
||||||
|
if (res.writableEnded || res.destroyed) return;
|
||||||
|
try {
|
||||||
|
res.write(': keepalive\n\n');
|
||||||
|
} catch {
|
||||||
|
// res may have been destroyed between the writableEnded check and the
|
||||||
|
// write — swallow so the heartbeat is a no-op on a dead socket.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasFired) {
|
||||||
|
hasFired = true;
|
||||||
|
logEvent('info', 'heartbeat_active', { interval_ms: intervalMs, request_id: requestId });
|
||||||
|
}
|
||||||
|
handle = setTimeout(onFire, intervalMs);
|
||||||
|
};
|
||||||
|
handle = setTimeout(onFire, intervalMs);
|
||||||
|
return {
|
||||||
|
reset: () => {
|
||||||
|
if (handle) { clearTimeout(handle); handle = setTimeout(onFire, intervalMs); }
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
if (handle) { clearTimeout(handle); handle = null; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider registry ─────────────────────────────────────────────────────
|
// ── Provider registry ─────────────────────────────────────────────────────
|
||||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
||||||
// ~/.olp/config.json has providers.enabled.X = true.
|
// ~/.olp/config.json has providers.enabled.X = true.
|
||||||
@@ -567,9 +780,34 @@ async function handleHealth(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D69 (ADR 0011): compute opt-in /health.anonymousKey. The field is added
|
||||||
|
// to BOTH trimmed and full payloads (the trimmed payload's whole purpose
|
||||||
|
// is to be readable by anonymous clients so they can self-bootstrap into
|
||||||
|
// a real key). Three prerequisites must hold (see startup warns above):
|
||||||
|
// (1) auth.advertise_anonymous_key === true
|
||||||
|
// (2) auth.allow_anonymous === true
|
||||||
|
// (3) findAdvertisedKey() returns a non-null active manifest
|
||||||
|
// When any fails, the field is absent (NOT null) — preserves the v0.3.x
|
||||||
|
// /health payload shape for clients that strict-validate keys.
|
||||||
|
let anonymousKey = null;
|
||||||
|
if (_authConfig.advertise_anonymous_key === true && _authConfig.allow_anonymous === true) {
|
||||||
|
try {
|
||||||
|
const adv = findAdvertisedKey();
|
||||||
|
if (adv && typeof adv.plaintext_advertise === 'string' && adv.plaintext_advertise.length > 0) {
|
||||||
|
anonymousKey = adv.plaintext_advertise;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Filesystem error reading manifests is non-fatal; just omit the field.
|
||||||
|
anonymousKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isGated && !isOwner) {
|
if (isGated && !isOwner) {
|
||||||
// Trimmed payload per § 7.1.
|
// Trimmed payload per § 7.1. D69: include anonymousKey here too — zero-
|
||||||
return sendJSON(res, 200, { ok: true, version: VERSION });
|
// config olp-connect callers read it BEFORE they have a key.
|
||||||
|
const trimmed = { ok: true, version: VERSION };
|
||||||
|
if (anonymousKey !== null) trimmed.anonymousKey = anonymousKey;
|
||||||
|
return sendJSON(res, 200, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full payload (owner OR /health removed from owner_only_endpoints).
|
// Full payload (owner OR /health removed from owner_only_endpoints).
|
||||||
@@ -590,11 +828,13 @@ async function handleHealth(req, res) {
|
|||||||
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendJSON(res, 200, {
|
const fullPayload = {
|
||||||
ok: true,
|
ok: true,
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
providers: { enabled, available, status: providerStatuses },
|
providers: { enabled, available, status: providerStatuses },
|
||||||
});
|
};
|
||||||
|
if (anonymousKey !== null) fullPayload.anonymousKey = anonymousKey;
|
||||||
|
sendJSON(res, 200, fullPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -702,6 +942,22 @@ function handleModels(req, res) {
|
|||||||
async function handleChatCompletions(req, res) {
|
async function handleChatCompletions(req, res) {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// D63 (ADR 0010 § Phase 4 D61-D63): request counters for /status. Increment
|
||||||
|
// at the top (every request counts, even authentication failures); decrement
|
||||||
|
// in a single `res.on('close')` listener that fires on both normal end and
|
||||||
|
// client disconnect, ensuring _activeRequests cannot leak. `res.on('finish')`
|
||||||
|
// alone would miss client-disconnect-before-finish cases.
|
||||||
|
_totalRequests++;
|
||||||
|
_activeRequests++;
|
||||||
|
let _activeDecremented = false;
|
||||||
|
const _decrementActive = () => {
|
||||||
|
if (_activeDecremented) return;
|
||||||
|
_activeDecremented = true;
|
||||||
|
_activeRequests = Math.max(0, _activeRequests - 1);
|
||||||
|
};
|
||||||
|
res.on('close', _decrementActive);
|
||||||
|
res.on('finish', _decrementActive);
|
||||||
|
|
||||||
// Audit context — fields populated as the request proceeds; § 8 schema.
|
// Audit context — fields populated as the request proceeds; § 8 schema.
|
||||||
// Fired on res.on('finish') below regardless of success / error path.
|
// Fired on res.on('finish') below regardless of success / error path.
|
||||||
const auditCtx = {
|
const auditCtx = {
|
||||||
@@ -1213,6 +1469,12 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: e.message,
|
error: e.message,
|
||||||
});
|
});
|
||||||
|
_pushError({
|
||||||
|
error: e,
|
||||||
|
provider: streamProvider,
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
return sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
return sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
||||||
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
||||||
}
|
}
|
||||||
@@ -1247,8 +1509,16 @@ async function handleChatCompletions(req, res) {
|
|||||||
// after the response is fully sent, which is too late for our abort
|
// after the response is fully sent, which is too late for our abort
|
||||||
// propagation needs. Without this wire-up the cache layer would never
|
// propagation needs. Without this wire-up the cache layer would never
|
||||||
// learn of disconnect → potential orphan spawns.
|
// learn of disconnect → potential orphan spawns.
|
||||||
|
// Forward declaration of the heartbeat handle so the close-listener
|
||||||
|
// installed here can stop the timer when the client disconnects.
|
||||||
|
// (The handle itself is assigned a few lines below — startHeartbeat
|
||||||
|
// is invoked after eager-headers run, but the close listener must
|
||||||
|
// already be wired so a disconnect during the source-factory await
|
||||||
|
// path still propagates iterator return + heartbeat stop.)
|
||||||
|
let heartbeatRef = null;
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
try { stream.return?.(); } catch { /* best-effort */ }
|
try { stream.return?.(); } catch { /* best-effort */ }
|
||||||
|
try { heartbeatRef?.stop?.(); } catch { /* best-effort */ }
|
||||||
};
|
};
|
||||||
res.on('close', onClose);
|
res.on('close', onClose);
|
||||||
|
|
||||||
@@ -1279,6 +1549,37 @@ async function handleChatCompletions(req, res) {
|
|||||||
// that pre-first-chunk errors can still produce a JSON 502 (matching
|
// that pre-first-chunk errors can still produce a JSON 502 (matching
|
||||||
// the buffered path). Calling writeHead unconditionally was the D14
|
// the buffered path). Calling writeHead unconditionally was the D14
|
||||||
// defect.
|
// defect.
|
||||||
|
//
|
||||||
|
// D61 (ADR 0010 § Phase 4 D61-D63): when streaming heartbeat is
|
||||||
|
// enabled (streaming.heartbeat_interval_ms > 0), headers must be
|
||||||
|
// flushed BEFORE the first chunk so the `: keepalive\n\n` SSE comment
|
||||||
|
// frame has somewhere to land. Without eager-headers the heartbeat
|
||||||
|
// would write into a buffer that the client never sees (OCP db11105
|
||||||
|
// lesson — the bug was that `ensureHeaders()` returned `false` after
|
||||||
|
// headers were sent for the "connection-dead" case, which made all
|
||||||
|
// post-headers chunks no-ops). We send eager-headers only when
|
||||||
|
// heartbeat is enabled — preserving D14's lazy-headers default so
|
||||||
|
// pre-first-chunk errors still surface as JSON 502s when heartbeat
|
||||||
|
// is off (the legacy / current path).
|
||||||
|
const heartbeatIntervalMs = _streamingConfig.heartbeat_interval_ms ?? 0;
|
||||||
|
const eagerHeaders = heartbeatIntervalMs > 0;
|
||||||
|
if (eagerHeaders && !res.headersSent) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
...SSE_DEFAULT_HEADERS,
|
||||||
|
...streamHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat instance is per-attached-client (each call to
|
||||||
|
// startHeartbeat creates its own timer state; source and attached
|
||||||
|
// clients write to different `res` objects and therefore each get
|
||||||
|
// their own timer). Heartbeat fires only AFTER headers are sent
|
||||||
|
// (eager-headers gate above); when intervalMs is 0 the returned
|
||||||
|
// object is a no-op stub so `reset()` / `stop()` calls are cheap.
|
||||||
|
const heartbeat = startHeartbeat(res, heartbeatIntervalMs, requestId);
|
||||||
|
// Expose to the onClose listener so a client disconnect cancels the
|
||||||
|
// timer immediately rather than waiting for the finally block.
|
||||||
|
heartbeatRef = heartbeat;
|
||||||
|
|
||||||
const streamedChunks = [];
|
const streamedChunks = [];
|
||||||
let firstChunkEmitted = false;
|
let firstChunkEmitted = false;
|
||||||
@@ -1294,6 +1595,12 @@ async function handleChatCompletions(req, res) {
|
|||||||
error: irChunk.error,
|
error: irChunk.error,
|
||||||
});
|
});
|
||||||
auditCtx.error_code = 'streaming_error_after_first_chunk';
|
auditCtx.error_code = 'streaming_error_after_first_chunk';
|
||||||
|
_pushError({
|
||||||
|
error: { message: irChunk.error ?? 'streaming_error_after_first_chunk', code: 'SPAWN_FAILED' },
|
||||||
|
provider: streamProvider,
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: null, // headers already sent; status_code intentionally null — record by error code only (D61-D63 reviewer P2-2: explicit intent over numeric-but-meaningless 200)
|
||||||
|
});
|
||||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||||
res.write(SSE_DONE);
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
@@ -1304,15 +1611,16 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
...SSE_DEFAULT_HEADERS,
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
...streamHeaders,
|
...streamHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
streamedChunks.push(irChunk);
|
streamedChunks.push(irChunk);
|
||||||
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
|
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
|
||||||
|
// D61: reset the heartbeat timer on every real chunk so the next
|
||||||
|
// keepalive frame fires only after another silent window of
|
||||||
|
// intervalMs ms (rather than rapidly chasing the data stream).
|
||||||
|
heartbeat.reset();
|
||||||
firstChunkEmitted = true;
|
firstChunkEmitted = true;
|
||||||
|
|
||||||
if (irChunk.type === 'stop') {
|
if (irChunk.type === 'stop') {
|
||||||
@@ -1349,10 +1657,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// yielded), emit headers + [DONE] so the response is still valid SSE.
|
// yielded), emit headers + [DONE] so the response is still valid SSE.
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
...SSE_DEFAULT_HEADERS,
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
...streamHeaders,
|
...streamHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1373,6 +1678,12 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: e.message,
|
error: e.message,
|
||||||
});
|
});
|
||||||
|
_pushError({
|
||||||
|
error: e,
|
||||||
|
provider: streamProvider,
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: null, // headers already sent; status_code intentionally null — record by error code only (D61-D63 reviewer P2-2)
|
||||||
|
});
|
||||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||||
res.write(SSE_DONE);
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
@@ -1384,6 +1695,12 @@ async function handleChatCompletions(req, res) {
|
|||||||
error: e.message,
|
error: e.message,
|
||||||
});
|
});
|
||||||
auditCtx.error_code = e?.code ?? 'provider_error';
|
auditCtx.error_code = e?.code ?? 'provider_error';
|
||||||
|
_pushError({
|
||||||
|
error: e,
|
||||||
|
provider: streamProvider,
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
||||||
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
||||||
@@ -1396,6 +1713,10 @@ async function handleChatCompletions(req, res) {
|
|||||||
// source caller acquired). The req-close listener is detached here
|
// source caller acquired). The req-close listener is detached here
|
||||||
// so it doesn't leak past the response lifetime.
|
// so it doesn't leak past the response lifetime.
|
||||||
res.removeListener('close', onClose);
|
res.removeListener('close', onClose);
|
||||||
|
// D61: cancel the heartbeat timer on every exit path (normal stop,
|
||||||
|
// error, generator-exhausted, client-disconnect). The no-op stub
|
||||||
|
// returned when intervalMs<=0 makes this cheap when disabled.
|
||||||
|
heartbeat.stop();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1411,6 +1732,12 @@ async function handleChatCompletions(req, res) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// executeWithFallback throws only on programming errors (empty chain).
|
// executeWithFallback throws only on programming errors (empty chain).
|
||||||
logEvent('error', 'fallback_engine_error', { error: e.message });
|
logEvent('error', 'fallback_engine_error', { error: e.message });
|
||||||
|
_pushError({
|
||||||
|
error: e,
|
||||||
|
provider: null,
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
return sendError(res, 500, 'Internal server error', 'internal_error',
|
return sendError(res, 500, 'Internal server error', 'internal_error',
|
||||||
olpErrorHeaders({ startMs, model: ir.model }));
|
olpErrorHeaders({ startMs, model: ir.model }));
|
||||||
}
|
}
|
||||||
@@ -1434,6 +1761,20 @@ async function handleChatCompletions(req, res) {
|
|||||||
triedProviders,
|
triedProviders,
|
||||||
error: originalError?.message,
|
error: originalError?.message,
|
||||||
});
|
});
|
||||||
|
// D62: surface the chain-exhausted / provider-error onto the recentErrors
|
||||||
|
// ring so /status (D63) shows it to owner identities. The ring filter
|
||||||
|
// requires either a ProviderError code or statusCode >= 500; spawn errors
|
||||||
|
// typically map to 502 below — fall back to 500 if no statusCode is
|
||||||
|
// present so a programming-error throw still records.
|
||||||
|
{
|
||||||
|
const httpStatus = originalError?.statusCode ?? originalError?.status ?? 502;
|
||||||
|
_pushError({
|
||||||
|
error: originalError ?? new Error('unknown chain-exhausted error'),
|
||||||
|
provider: providerUsed ?? (chain[0]?.provider ?? null),
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
statusCode: httpStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Emit exhausted header if more than one provider was tried
|
// Emit exhausted header if more than one provider was tried
|
||||||
const exhaustedHeader = triedProviders.length > 1
|
const exhaustedHeader = triedProviders.length > 1
|
||||||
@@ -1540,11 +1881,13 @@ async function handleChatCompletions(req, res) {
|
|||||||
// Streaming response path: burst replay from buffered chunks.
|
// Streaming response path: burst replay from buffered chunks.
|
||||||
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
||||||
// (Single-hop cache-miss streaming is handled by the real-streaming path above.)
|
// (Single-hop cache-miss streaming is handled by the real-streaming path above.)
|
||||||
|
// D61: SSE headers are emitted from the shared SSE_DEFAULT_HEADERS
|
||||||
|
// constant so all streaming paths agree on `X-Accel-Buffering: no`.
|
||||||
|
// Heartbeat is intentionally not wired in the buffered replay path: the
|
||||||
|
// burst write completes synchronously into the socket buffer, so silent
|
||||||
|
// windows are bounded by the chunk count, not by provider think-time.
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
...SSE_DEFAULT_HEADERS,
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
...headers,
|
...headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1715,6 +2058,89 @@ async function handleManagementQuota(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v0/management/status
|
||||||
|
*
|
||||||
|
* D63 (ADR 0010 § Phase 4 D61-D63). Combined snapshot of process lifecycle,
|
||||||
|
* per-provider health, request counters, cache stats, and recentErrors[20].
|
||||||
|
* Owner-only_block per ADR 0008 §8 (matches the rest of the management
|
||||||
|
* namespace).
|
||||||
|
*
|
||||||
|
* Port of OCP server.mjs:1151-1188 adapted to OLP's namespace + identity model:
|
||||||
|
* - Path is /v0/management/status (not root /status — OLP's namespace
|
||||||
|
* discipline is stricter than OCP's).
|
||||||
|
* - No "plan" field (OCP's plan came from the Anthropic Pro/Max API which
|
||||||
|
* OLP does not call directly).
|
||||||
|
* - Adds provider-quota subset + cache stats + cumulative request counters.
|
||||||
|
* - recentErrors is filtered upstream (no 401/403 entries) and capped at 20.
|
||||||
|
*
|
||||||
|
* Response shape:
|
||||||
|
* {
|
||||||
|
* ok: true,
|
||||||
|
* version: "0.4.0-phase4",
|
||||||
|
* uptime_ms: <number>,
|
||||||
|
* uptime_human: "Xh Ym Zs",
|
||||||
|
* started_at: <ISO8601>,
|
||||||
|
* providers: {
|
||||||
|
* enabled: <number>,
|
||||||
|
* available: <number>,
|
||||||
|
* status: { [providerKey]: { ok, ...healthCheck, activeSpawns } }
|
||||||
|
* },
|
||||||
|
* stats: {
|
||||||
|
* total_requests: <number>,
|
||||||
|
* active_requests: <number>,
|
||||||
|
* cache: { hits, misses, size, inflightCount, ... },
|
||||||
|
* },
|
||||||
|
* recent_errors: [{ time, message, code, provider, path, status_code }, ...],
|
||||||
|
* generated_at: <ISO8601>,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async function handleManagementStatus(req, res) {
|
||||||
|
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/status',
|
||||||
|
async (_req, res2, _identity, _auditCtx) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const uptimeMs = now - _serverStartMs;
|
||||||
|
const hours = Math.floor(uptimeMs / 3600000);
|
||||||
|
const minutes = Math.floor((uptimeMs % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((uptimeMs % 60000) / 1000);
|
||||||
|
|
||||||
|
// Provider status subset: ok + activeSpawns + a single optional error
|
||||||
|
// string (mirrors the /health full payload shape but trimmed — /status
|
||||||
|
// is for owner monitoring, not external probes).
|
||||||
|
const providerStatus = {};
|
||||||
|
for (const [name, provider] of loadedProviders) {
|
||||||
|
const activeSpawns = getActiveSpawnCount(name);
|
||||||
|
try {
|
||||||
|
const hc = await provider.healthCheck();
|
||||||
|
providerStatus[name] = { ...hc, activeSpawns };
|
||||||
|
} catch (err) {
|
||||||
|
providerStatus[name] = { ok: false, error: err?.message ?? String(err), activeSpawns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ok: true,
|
||||||
|
version: VERSION,
|
||||||
|
uptime_ms: uptimeMs,
|
||||||
|
uptime_human: `${hours}h ${minutes}m ${seconds}s`,
|
||||||
|
started_at: new Date(_serverStartMs).toISOString(),
|
||||||
|
providers: {
|
||||||
|
enabled: loadedProviders.size,
|
||||||
|
available: listAllProviderNames().length,
|
||||||
|
status: providerStatus,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total_requests: _totalRequests,
|
||||||
|
active_requests: _activeRequests,
|
||||||
|
cache: cacheStore.stats(),
|
||||||
|
},
|
||||||
|
recent_errors: recentErrors.slice(),
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
sendJSON(res2, 200, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cache/stats
|
* GET /cache/stats
|
||||||
* Live in-memory CacheStore stats. Owner-only_block.
|
* Live in-memory CacheStore stats. Owner-only_block.
|
||||||
@@ -1764,6 +2190,9 @@ async function router(req, res) {
|
|||||||
if (method === 'GET' && path === '/v0/management/quota') {
|
if (method === 'GET' && path === '/v0/management/quota') {
|
||||||
return await handleManagementQuota(req, res);
|
return await handleManagementQuota(req, res);
|
||||||
}
|
}
|
||||||
|
if (method === 'GET' && path === '/v0/management/status') {
|
||||||
|
return await handleManagementStatus(req, res);
|
||||||
|
}
|
||||||
if (method === 'GET' && path === '/cache/stats') {
|
if (method === 'GET' && path === '/cache/stats') {
|
||||||
return await handleCacheStats(req, res);
|
return await handleCacheStats(req, res);
|
||||||
}
|
}
|
||||||
@@ -1772,6 +2201,12 @@ async function router(req, res) {
|
|||||||
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logEvent('error', 'unhandled_request_error', { method, path, error: e?.message });
|
logEvent('error', 'unhandled_request_error', { method, path, error: e?.message });
|
||||||
|
_pushError({
|
||||||
|
error: e,
|
||||||
|
provider: null,
|
||||||
|
path,
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
sendError(res, 500, 'Internal server error', 'internal_error');
|
sendError(res, 500, 'Internal server error', 'internal_error');
|
||||||
}
|
}
|
||||||
|
|||||||
+1863
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user