From 53afea47cae294820518ade3baaf28d4ceb848b1 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 26 May 2026 09:35:23 +1000 Subject: [PATCH] =?UTF-8?q?feat+test+docs:=20D71+D72+D73=20=E2=80=94=20olp?= =?UTF-8?q?-plugin/=20(OpenClaw=20/olp=20Telegram+Discord)=20+=20docs/inte?= =?UTF-8?q?grations/*.md=20+=20README=20cross-refs=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11 IDR (plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together; README index links all). After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter + port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor / D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay. ## D71 — olp-plugin/ (OpenClaw gateway plugin) Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482 lines) as the /olp Telegram+Discord slash command, but MINUS mutations (no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs — all of these require SSH out of chat for security). Plugin shape: - olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand - olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl default http://127.0.0.1:4567 (matches D60) - olp-plugin/package.json — minimal: name/version/type:module + OpenClaw discovery block - olp-plugin/README.md — install + configure + use docs; documents the "no mutations from chat" security stance and the dedicated-bot-key pattern (don't share maintainer's personal key with the bot) Subcommand parity with olp CLI (D64-D67): - /olp status → GET /v0/management/status (owner-only) - /olp health → GET /health (public-ok) - /olp usage → GET /v0/management/dashboard-data (owner-only) - /olp models → GET /v1/models (public-ok) - /olp cache → GET /cache/stats (owner-only) - /olp providers → local cross-ref (public-ok) - /olp chain show [] → local (public-ok, advisory if no FS access — defer to ssh + olp chain show) - /olp doctor → informational (HTTP doctor endpoint deferred; advisory to ssh + olp doctor for live use) - /olp help → usage text Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for the 4096-char message limit. No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses fetch + node builtins only. ## D72 — docs/integrations/*.md (6 IDE pages + index) Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope posture for Claude Code: - continue.md ✅ — config.yaml (NOT config.json); apiBase; requestOptions.headers - cline.md ✅ — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning - cursor.md ⚠️ — known base-URL fragility; only enable models OLP serves - aider.md ✅ — OPENAI_API_BASE env + openai/ prefix; .env support - claude-code.md ❌ — explicitly NOT supported per ADR 0010 § /v1/messages defer rationale; recommended alternative: Cline + OLP - openclaw.md ✅ — install olp-plugin via CLI or symlink; configure apiKey; restart gateway Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. docs/integrations/README.md is the index. ## D73 — README cross-references - New § "IDE Setup" links to docs/integrations/README.md - New § "Telegram / Discord Usage" — install + configure + restart + use - Quick Start mentions olp-connect as family-onboarding command - package.json `files` field extended to include olp-plugin/ so the published tarball ships the plugin ## Test count 672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters / dispatch / error paths). All 696 pass locally. ## Scope discipline - server.mjs UNTOUCHED (plugin consumes EXISTING endpoints) - No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport) - No /v1/messages (out of Phase 4 per ADR 0010) - No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json `files` extended for olp-plugin/ publication) ## Implementor flagged for reviewer 1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When future phase exposes /v0/management/doctor, swap advisory branch for real fetchJSON + fmtDoctor (already implemented + tested). 2. /olp providers + chain show have no FS access (plugin runs in OpenClaw gateway process); registry read via lazy-imported models-registry.json from repo root. For live enabled-state visibility users still need /olp status (owner-tier) or ssh + olp providers / olp chain show. 3. No live-server wire test in Suite 35 — existing Suites 31/32 already cover the integration path against the same endpoints; mock-fetch in Suite 35 is sufficient signal for the plugin layer. ## Authority - ADR 0010 § Phase 4 D-day plan D71-D73 line - OCP ocp-plugin/index.js (port reference) - ADR 0010 § Out-of-Phase-4-scope (claude-code.md ❌ rationale) - 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art survey IDE-specific quirks for cline/cursor/continue docs) Co-authored-by: dtzp555 Co-authored-by: Claude Opus 4.7 --- README.md | 71 +++++ docs/integrations/README.md | 47 ++++ docs/integrations/aider.md | 106 ++++++++ docs/integrations/claude-code.md | 89 +++++++ docs/integrations/cline.md | 94 +++++++ docs/integrations/continue.md | 95 +++++++ docs/integrations/cursor.md | 116 ++++++++ docs/integrations/openclaw.md | 146 +++++++++++ olp-plugin/README.md | 153 +++++++++++ olp-plugin/index.js | 437 +++++++++++++++++++++++++++++++ olp-plugin/openclaw.plugin.json | 22 ++ olp-plugin/package.json | 15 ++ package.json | 13 + test-features.mjs | 397 ++++++++++++++++++++++++++++ 14 files changed, 1801 insertions(+) create mode 100644 docs/integrations/README.md create mode 100644 docs/integrations/aider.md create mode 100644 docs/integrations/claude-code.md create mode 100644 docs/integrations/cline.md create mode 100644 docs/integrations/continue.md create mode 100644 docs/integrations/cursor.md create mode 100644 docs/integrations/openclaw.md create mode 100644 olp-plugin/README.md create mode 100644 olp-plugin/index.js create mode 100644 olp-plugin/openclaw.plugin.json create mode 100644 olp-plugin/package.json diff --git a/README.md b/README.md index 77c6446..e76e1b3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ olp start # point your IDE at http://localhost:4567/v1/chat/completions with the OLP API key from `olp keys list`. ``` +**Family-on-LAN onboarding (D68-D70).** For other devices on the same network, run on the client device: + +```bash +# Detects Cline / Continue.dev / Cursor / Aider / OpenClaw installed locally +# and writes per-tool config pointing at the OLP host. Requires `python3`. +olp-connect +``` + +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 @@ -181,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 ` 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) 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. diff --git a/docs/integrations/README.md b/docs/integrations/README.md new file mode 100644 index 0000000..6328aee --- /dev/null +++ b/docs/integrations/README.md @@ -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). diff --git a/docs/integrations/aider.md b/docs/integrations/aider.md new file mode 100644 index 0000000..f7f7dff --- /dev/null +++ b/docs/integrations/aider.md @@ -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 `). + +## 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. diff --git a/docs/integrations/claude-code.md b/docs/integrations/claude-code.md new file mode 100644 index 0000000..fbec06c --- /dev/null +++ b/docs/integrations/claude-code.md @@ -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. diff --git a/docs/integrations/cline.md b/docs/integrations/cline.md new file mode 100644 index 0000000..99f9284 --- /dev/null +++ b/docs/integrations/cline.md @@ -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 `). + +## 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. diff --git a/docs/integrations/continue.md b/docs/integrations/continue.md new file mode 100644 index 0000000..3f19bc5 --- /dev/null +++ b/docs/integrations/continue.md @@ -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 ` 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: ` — 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. diff --git a/docs/integrations/cursor.md b/docs/integrations/cursor.md new file mode 100644 index 0000000..6044fab --- /dev/null +++ b/docs/integrations/cursor.md @@ -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 `). + +## 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. diff --git a/docs/integrations/openclaw.md b/docs/integrations/openclaw.md new file mode 100644 index 0000000..a25482c --- /dev/null +++ b/docs/integrations/openclaw.md @@ -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=`), 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:///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). diff --git a/olp-plugin/README.md b/olp-plugin/README.md new file mode 100644 index 0000000..6204b6e --- /dev/null +++ b/olp-plugin/README.md @@ -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=` 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 ` 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. diff --git a/olp-plugin/index.js b/olp-plugin/index.js new file mode 100644 index 0000000..2e59e75 --- /dev/null +++ b/olp-plugin/index.js @@ -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 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)) }; + }, + }); +} diff --git a/olp-plugin/openclaw.plugin.json b/olp-plugin/openclaw.plugin.json new file mode 100644 index 0000000..864fb35 --- /dev/null +++ b/olp-plugin/openclaw.plugin.json @@ -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"] + } +} diff --git a/olp-plugin/package.json b/olp-plugin/package.json new file mode 100644 index 0000000..16b5975 --- /dev/null +++ b/olp-plugin/package.json @@ -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" + } +} diff --git a/package.json b/package.json index 5ea3891..343d451 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,19 @@ "olp-audit-rotate": "node bin/olp-audit-rotate.mjs", "olp-connect": "bash bin/olp-connect" }, + "files": [ + "server.mjs", + "bin/", + "lib/", + "olp-plugin/", + "models-registry.json", + "dashboard.html", + "README.md", + "ALIGNMENT.md", + "CHANGELOG.md", + "LICENSE", + "docs/" + ], "engines": { "node": ">=18" }, diff --git a/test-features.mjs b/test-features.mjs index 493af49..e72b3a5 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -14394,3 +14394,400 @@ describe('Suite 34 — D68-D70 /health.anonymousKey + plaintext_advertise (ADR 0 }); }); }); + +// ── Suite 35: D71 olp-plugin/ smoke tests (ADR 0010 § Phase 4 D71-D73) ───── +// +// The plugin's default export takes an OpenClaw `api` object and registers a +// command — we can't fully unit-test the registration without mocking the +// gateway. Instead we test the exported helpers + dispatcher with the global +// fetch swapped out for a mock. These cover: +// +// 35a — mono() wrapping for chat surfaces +// 35b — bar() boundary behaviour (clamping + width) +// 35c — resolveProxyUrl precedence (env > env-port > config > default) +// 35d — truncateForChat truncation behaviour +// 35e — fmtStatus / fmtHealth / fmtUsage / fmtModels / fmtCache / +// fmtProviders / fmtChainShow / fmtDoctor render expected fields +// 35f — dispatch() unknown subcommand → help message +// 35g — dispatch() routes status/health/usage/models/cache to the right URL +// with Authorization: Bearer header +// 35h — dispatch() doctor returns the "use SSH" advisory (HTTP doctor +// endpoint not yet shipped) +// 35i — dispatch() catches fetch errors and emits "OLP error: ..." + +import { + mono as plugMono, + bar as plugBar, + statusIcon as plugStatusIcon, + truncateForChat as plugTruncate, + resolveProxyUrl as plugResolveProxyUrl, + fmtStatus as plugFmtStatus, + fmtHealth as plugFmtHealth, + fmtUsage as plugFmtUsage, + fmtModels as plugFmtModels, + fmtCache as plugFmtCache, + fmtProviders as plugFmtProviders, + fmtChainShow as plugFmtChainShow, + fmtDoctor as plugFmtDoctor, + cmdHelp as plugCmdHelp, + dispatch as plugDispatch, +} from './olp-plugin/index.js'; + +describe('Suite 35 — D71 olp-plugin/ smoke tests (ADR 0010 § Phase 4 D71-D73)', () => { + + // ── 35a — mono() ──────────────────────────────────────────────────────── + + it('35a — mono() wraps text in triple-backtick code block', () => { + const out = plugMono('hello\nworld'); + assert.equal(out, '```\nhello\nworld\n```'); + }); + + // ── 35b — bar() ───────────────────────────────────────────────────────── + + it('35b — bar() clamps pct ∈ [0,1] and respects width', () => { + assert.equal(plugBar(0, 8), '░░░░░░░░'); + assert.equal(plugBar(1, 8), '████████'); + assert.equal(plugBar(0.5, 8), '████░░░░'); + assert.equal(plugBar(-1, 8), '░░░░░░░░', 'negative pct clamps to 0'); + assert.equal(plugBar(2, 8), '████████', 'pct > 1 clamps to 1'); + assert.equal(plugBar(NaN, 8), '░░░░░░░░', 'NaN treated as 0'); + assert.equal(plugBar(0.5).length, 16, 'default width = 16'); + }); + + it('35b-icon — statusIcon() maps ok/degraded/fail to expected glyphs', () => { + assert.equal(plugStatusIcon('ok'), '🟢'); + assert.equal(plugStatusIcon(true), '🟢'); + assert.equal(plugStatusIcon('degraded'), '🟡'); + assert.equal(plugStatusIcon('warn'), '🟡'); + assert.equal(plugStatusIcon('fail'), '🔴'); + assert.equal(plugStatusIcon(false), '🔴'); + }); + + // ── 35c — resolveProxyUrl precedence ──────────────────────────────────── + + it('35c — resolveProxyUrl precedence: OLP_PROXY_URL > OLP_PORT > config.proxyUrl > default', () => { + // 1. Default fallback + assert.equal(plugResolveProxyUrl({ env: {}, config: {} }), 'http://127.0.0.1:4567'); + // 2. config.proxyUrl wins over default + assert.equal( + plugResolveProxyUrl({ env: {}, config: { proxyUrl: 'http://10.0.0.5:9999' } }), + 'http://10.0.0.5:9999', + ); + // 3. OLP_PORT wins over config.proxyUrl + assert.equal( + plugResolveProxyUrl({ env: { OLP_PORT: '8000' }, config: { proxyUrl: 'http://config:1' } }), + 'http://127.0.0.1:8000', + ); + // 4. OLP_PROXY_URL wins over OLP_PORT + config + assert.equal( + plugResolveProxyUrl({ + env: { OLP_PROXY_URL: 'http://env-url:1234', OLP_PORT: '8000' }, + config: { proxyUrl: 'http://config:1' }, + }), + 'http://env-url:1234', + ); + }); + + // ── 35d — truncateForChat ─────────────────────────────────────────────── + + it('35d — truncateForChat passes short text untouched + truncates long text with marker', () => { + const short = 'hello world'; + assert.equal(plugTruncate(short), short); + const long = 'A'.repeat(5000); + const truncated = plugTruncate(long, 1000); + assert.ok(truncated.length <= 1000); + assert.match(truncated, /truncated, use SSH for full/); + assert.ok(truncated.startsWith('AAA')); + }); + + // ── 35e — Formatters ──────────────────────────────────────────────────── + + it('35e-1 — fmtStatus() renders version + uptime + provider list + recent errors', () => { + const body = { + ok: true, + version: '0.4.0-phase4', + uptime_human: '1h 2m 3s', + providers: { + enabled: 2, + available: 3, + status: { + anthropic: { ok: true, activeSpawns: 0 }, + openai: { ok: false, error: 'CLI not found', activeSpawns: 0 }, + }, + }, + stats: { total_requests: 42, active_requests: 1, cache: { hits: 10, misses: 5, size: 7 } }, + recent_errors: [{ time: '2026-05-26T11:22:33Z', provider: 'openai', message: 'spawn EACCES' }], + }; + const out = plugFmtStatus(body); + assert.match(out, /0\.4\.0-phase4/); + assert.match(out, /1h 2m 3s/); + assert.match(out, /Providers: 2 enabled \/ 3 available/); + assert.match(out, /anthropic/); + assert.match(out, /openai.*CLI not found/); + assert.match(out, /Requests: 42 total/); + assert.match(out, /Cache: 10 hit \/ 5 miss/); + assert.match(out, /Recent errors \(1\)/); + assert.match(out, /spawn EACCES/); + }); + + it('35e-2 — fmtHealth() renders ok + version + provider list', () => { + const body = { + ok: true, + version: '0.4.0-phase4', + uptime_human: '5m', + providers: { anthropic: { ok: true }, openai: { ok: false } }, + }; + const out = plugFmtHealth(body); + assert.match(out, /Status: ok/); + assert.match(out, /v0\.4\.0-phase4/); + assert.match(out, /anthropic/); + assert.match(out, /openai/); + }); + + it('35e-3 — fmtUsage() renders 24h window + per-provider quota + top fallback chains', () => { + const body = { + cache_hit_24h: 0.42, + quota: [ + { name: 'anthropic', percent_used: 33 }, + { name: 'mistral', percent_used: null }, + ], + top_fallback_chains_24h: [ + { count: 5, chain: ['anthropic', 'openai'] }, + ], + }; + const out = plugFmtUsage(body); + assert.match(out, /Cache hit \(24h\): 42\.0%/); + assert.match(out, /anthropic.*33%/); + assert.match(out, /mistral.*no quota api/); + assert.match(out, /5 {2}anthropic → openai/); + }); + + it('35e-4 — fmtModels() handles empty + populated', () => { + assert.equal(plugFmtModels({ data: [] }), 'No models.'); + const out = plugFmtModels({ + data: [ + { id: 'claude-sonnet-4-5', owned_by: 'anthropic' }, + { id: 'gpt-5', owned_by: 'openai' }, + ], + }); + assert.match(out, /Models \(2\)/); + assert.match(out, /claude-sonnet-4-5\s+\(anthropic\)/); + assert.match(out, /gpt-5\s+\(openai\)/); + }); + + it('35e-5 — fmtCache() renders hit/miss/size/inflight', () => { + const out = plugFmtCache({ size: 12, hits: 100, misses: 25, inflightCount: 2, evictions: 1 }); + assert.match(out, /Entries: {8}12/); + assert.match(out, /Hits: {11}100/); + assert.match(out, /Misses: {9}25/); + assert.match(out, /Inflight: {7}2/); + assert.match(out, /Evictions: {6}1/); + }); + + it('35e-6 — fmtProviders() lists registry providers + enabled flags', () => { + const registry = { + providers: { + anthropic: { tier: 'D', models: ['m1', 'm2'] }, + openai: { tier: 'D', models: ['m3'], candidate: true }, + }, + }; + const out = plugFmtProviders(registry, { anthropic: true }); + assert.match(out, /anthropic\s+enabled/); + assert.match(out, /openai\s+disabled.*\(candidate\)/); + }); + + it('35e-7 — fmtChainShow() handles empty / all / single-target', () => { + assert.equal(plugFmtChainShow({}), 'No chains configured.'); + const chains = { + 'claude-sonnet-4-5': [{ provider: 'anthropic', model: 'claude-sonnet-4-5' }], + 'gpt-5': ['openai:gpt-5'], + }; + const all = plugFmtChainShow(chains); + assert.match(all, /claude-sonnet-4-5/); + assert.match(all, /gpt-5/); + const one = plugFmtChainShow(chains, 'gpt-5'); + assert.match(one, /gpt-5:/); + assert.match(one, /→ openai:gpt-5/); + const missing = plugFmtChainShow(chains, 'no-such-model'); + assert.match(missing, /not in routing\.chains/); + }); + + it('35e-8 — fmtDoctor() renders summary + checks + next_action', () => { + const body = { + summary: '2 OK, 1 WARN', + checks: [ + { id: 'server.running', status: 'ok', message: 'reachable' }, + { id: 'auth.owner_key_exists', status: 'warn', message: 'no owner key configured' }, + ], + fail_count: 0, warn_count: 1, ok_count: 1, + kind: 'fix_config', + next_action: { + ai_executable: ['npx olp-keys keygen --owner'], + human_required: ['capture the printed token'], + }, + }; + const out = plugFmtDoctor(body); + assert.match(out, /2 OK, 1 WARN/); + assert.match(out, /server\.running/); + assert.match(out, /auth\.owner_key_exists/); + assert.match(out, /fail=0 warn=1 ok=1/); + assert.match(out, /kind=fix_config/); + assert.match(out, /npx olp-keys keygen --owner/); + assert.match(out, /capture the printed token/); + }); + + // ── 35f — dispatch: unknown / help / "" ────────────────────────────────── + + it('35f-1 — dispatch("") returns cmdHelp() body', async () => { + const r = await plugDispatch('', { proxyUrl: 'http://x', registry: { providers: {} } }); + assert.equal(r.text, plugCmdHelp()); + }); + + it('35f-2 — dispatch("frobnicate") returns "Unknown subcommand:" + help', async () => { + const r = await plugDispatch('frobnicate', { proxyUrl: 'http://x', registry: { providers: {} } }); + assert.match(r.text, /Unknown subcommand: frobnicate/); + assert.match(r.text, /OLP Commands/); + }); + + it('35f-3 — dispatch("help") returns cmdHelp() with read-only disclaimer', async () => { + const r = await plugDispatch('help', { proxyUrl: 'http://x', registry: { providers: {} } }); + assert.match(r.text, /OLP Commands/); + assert.match(r.text, /Mutating commands.*NOT/); + }); + + // ── 35g — dispatch: routes status/health/usage/models/cache ────────────── + + it('35g — dispatch routes each subcommand to the right URL with Authorization header', async () => { + const calls = []; + const mockFetch = async (url, opts) => { + calls.push({ url, headers: opts?.headers ?? {} }); + // Per-route response stubs + if (url.endsWith('/v0/management/status')) { + return { ok: true, status: 200, json: async () => ({ + ok: true, version: '0.4.0', uptime_human: '1m', + providers: { enabled: 0, available: 3, status: {} }, + stats: { total_requests: 0, active_requests: 0 }, + recent_errors: [], + }) }; + } + if (url.endsWith('/health')) { + return { ok: true, status: 200, json: async () => ({ ok: true, version: '0.4.0' }) }; + } + if (url.endsWith('/v0/management/dashboard-data')) { + return { ok: true, status: 200, json: async () => ({ cache_hit_24h: 0.5 }) }; + } + if (url.endsWith('/v1/models')) { + return { ok: true, status: 200, json: async () => ({ data: [{ id: 'm1' }] }) }; + } + if (url.endsWith('/cache/stats')) { + return { ok: true, status: 200, json: async () => ({ size: 0, hits: 0, misses: 0, inflightCount: 0 }) }; + } + throw new Error(`unmocked url: ${url}`); + }; + + const opts = { + proxyUrl: 'http://test:9999', + apiKey: 'olp_TESTKEY', + registry: { providers: { anthropic: { tier: 'D', models: [] } } }, + fetchFn: mockFetch, + }; + + for (const sub of ['status', 'health', 'usage', 'models', 'cache']) { + calls.length = 0; + const r = await plugDispatch(sub, opts); + assert.equal(calls.length, 1, `${sub} should make exactly 1 HTTP call`); + assert.equal(calls[0].headers.Authorization, 'Bearer olp_TESTKEY', `${sub} must include Bearer header`); + assert.ok(r.text.length > 0, `${sub} should produce non-empty output`); + assert.ok(!/^OLP error/.test(r.text), `${sub} should not error; got: ${r.text}`); + } + + // URL routing per subcommand + calls.length = 0; + await plugDispatch('status', opts); + assert.equal(calls[0].url, 'http://test:9999/v0/management/status'); + + calls.length = 0; + await plugDispatch('usage', opts); + assert.equal(calls[0].url, 'http://test:9999/v0/management/dashboard-data'); + }); + + it('35g-providers — dispatch("providers") uses registry locally without HTTP', async () => { + let calls = 0; + const r = await plugDispatch('providers', { + proxyUrl: 'http://test:9999', + registry: { providers: { anthropic: { tier: 'D', models: [] }, openai: { tier: 'D', models: [] } } }, + fetchFn: async () => { calls++; return { ok: true, status: 200, json: async () => ({}) }; }, + }); + assert.equal(calls, 0, 'providers must NOT make an HTTP call'); + assert.match(r.text, /anthropic/); + assert.match(r.text, /openai/); + }); + + it('35g-chain — dispatch("chain show ") uses local chainsLocal arg', async () => { + const r = await plugDispatch('chain show foo-model', { + proxyUrl: 'http://test:9999', + registry: { providers: {} }, + chainsLocal: { 'foo-model': [{ provider: 'anthropic', model: 'foo' }] }, + fetchFn: async () => { throw new Error('should not fetch'); }, + }); + assert.match(r.text, /foo-model:/); + assert.match(r.text, /→.*anthropic/); + }); + + it('35g-chain-usage — dispatch("chain bogus") prints usage hint', async () => { + const r = await plugDispatch('chain bogus', { + proxyUrl: 'http://test:9999', + registry: { providers: {} }, + fetchFn: async () => { throw new Error('should not fetch'); }, + }); + assert.match(r.text, /Usage: \/olp chain show \[model\]/); + }); + + // ── 35h — dispatch: doctor advisory ───────────────────────────────────── + + it('35h — dispatch("doctor") returns the "use SSH" advisory (HTTP endpoint not yet shipped)', async () => { + let calls = 0; + const r = await plugDispatch('doctor', { + proxyUrl: 'http://test:9999', + registry: { providers: {} }, + fetchFn: async () => { calls++; return { ok: true, status: 200, json: async () => ({}) }; }, + }); + assert.equal(calls, 0, 'doctor must NOT make an HTTP call yet (Phase 4 surface is local only)'); + assert.match(r.text, /not yet wired through HTTP/); + assert.match(r.text, /Run `olp doctor` over SSH/); + }); + + // ── 35i — dispatch: error catching ────────────────────────────────────── + + it('35i — dispatch catches fetch errors and emits "OLP error: ..."', async () => { + const r = await plugDispatch('status', { + proxyUrl: 'http://test:9999', + apiKey: 'olp_X', + registry: { providers: {} }, + fetchFn: async () => { throw new Error('ECONNREFUSED'); }, + }); + assert.match(r.text, /^OLP error: ECONNREFUSED/); + }); + + it('35i-401 — dispatch surfaces 401 unauthorized with helpful hint', async () => { + const r = await plugDispatch('status', { + proxyUrl: 'http://test:9999', + apiKey: 'olp_BAD', + registry: { providers: {} }, + fetchFn: async () => ({ ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({}) }), + }); + assert.match(r.text, /OLP error.*401 unauthorized/); + assert.match(r.text, /owner-tier/); + }); + + it('35i-403 — dispatch surfaces 403 forbidden with helpful hint', async () => { + const r = await plugDispatch('cache', { + proxyUrl: 'http://test:9999', + apiKey: 'olp_GUEST', + registry: { providers: {} }, + fetchFn: async () => ({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}) }), + }); + assert.match(r.text, /OLP error.*403 forbidden/); + assert.match(r.text, /not owner-tier/); + }); +});