feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)

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 [<model>] → 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 <ip> 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 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-26 09:35:23 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 0bdecd1235
commit 53afea47ca
14 changed files with 1801 additions and 0 deletions
+71
View File
@@ -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`. # 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
@@ -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 <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.
+47
View File
@@ -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).
+106
View File
@@ -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.
+89
View File
@@ -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.
+94
View File
@@ -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.
+95
View File
@@ -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.
+116
View File
@@ -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.
+146
View File
@@ -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).
+153
View File
@@ -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.
+437
View File
@@ -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)) };
},
});
}
+22
View File
@@ -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"]
}
}
+15
View File
@@ -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"
}
}
+13
View File
@@ -18,6 +18,19 @@
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs", "olp-audit-rotate": "node bin/olp-audit-rotate.mjs",
"olp-connect": "bash bin/olp-connect" "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"
}, },
+397
View File
@@ -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 <model>") 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/);
});
});