mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
@@ -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 <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
|
||||
@@ -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)
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# OLP IDE & client integrations
|
||||
|
||||
This directory documents per-tool setup for the IDEs and AI clients OLP
|
||||
supports. Every page follows the same shape: one-line description, status
|
||||
icon, copy-paste-able config block, known issues, OLP-specific notes, and a
|
||||
one-line verification command.
|
||||
|
||||
## Index
|
||||
|
||||
| Tool | Status | Path | Notes |
|
||||
|---|---|---|---|
|
||||
| [Continue.dev](./continue.md) | ✅ Supported | VS Code / JetBrains extension | `config.yaml` (NOT `config.json`); supports custom headers |
|
||||
| [Cline](./cline.md) | ✅ Supported | VS Code extension | OpenAI-compatible provider; UI field occasionally vanishes (Cline #7128) |
|
||||
| [Cursor](./cursor.md) | ⚠️ Best-effort | Cursor editor | "Override OpenAI Base URL" — known fragile across releases |
|
||||
| [Aider](./aider.md) | ✅ Supported | terminal CLI | `OPENAI_API_BASE` env + `openai/` model prefix |
|
||||
| [Claude Code](./claude-code.md) | ❌ Not supported | terminal CLI | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline instead. |
|
||||
| [OpenClaw](./openclaw.md) | ✅ Supported | Telegram + Discord gateway | `/olp` slash command via the [`olp-plugin/`](../../olp-plugin/) plugin |
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Supported** — works against OLP's OpenAI-compatible `/v1/chat/completions`
|
||||
endpoint; the tool's IR fields flow through OLP's IR without lossy translation
|
||||
warnings on the documented chain.
|
||||
- ⚠️ **Best-effort** — works in current versions but the tool has known
|
||||
upstream bugs around base-URL configuration; expect occasional weirdness.
|
||||
- ❌ **Not supported** — the tool's wire protocol or transport is incompatible
|
||||
with what OLP serves; recommended alternative is documented on the page.
|
||||
|
||||
## How OLP's response headers help debugging
|
||||
|
||||
Every response carries (see [README § Response Headers](../../README.md#response-headers)):
|
||||
|
||||
- `X-OLP-Provider-Used` — which provider's plugin served the request
|
||||
- `X-OLP-Model-Used` — which model the served provider used
|
||||
- `X-OLP-Fallback-Hops` — `0` = primary chain entry served it
|
||||
- `X-OLP-Cache` — `hit | miss | bypass`
|
||||
- `X-OLP-Latency-Ms` — end-to-end latency at the proxy
|
||||
|
||||
When something looks wrong in an IDE, the first sanity check is `curl -i`
|
||||
against `/v1/chat/completions` with the same key — those headers tell you
|
||||
whether the IDE config is broken or OLP routed somewhere unexpected.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter; documents why `/v1/messages` is not supported and points to Cline as the recommended Anthropic-CLI replacement.
|
||||
- [ADR 0011](../adr/0011-anonymous-key-deployment-context.md) — trusted-LAN-only invariant for `auth.advertise_anonymous_key`.
|
||||
- [`bin/olp-connect`](../../bin/olp-connect) — automated client setup helper (D68-D70).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Aider + OLP
|
||||
|
||||
[Aider](https://aider.chat) is a terminal-native pair programmer that
|
||||
edits files in your local git repo and commits each change. It speaks
|
||||
OpenAI's `/v1/chat/completions` wire format via the `openai/` model
|
||||
prefix.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Aider v0.6x. Aider's OpenAI integration has been stable
|
||||
across many releases — this is the most reliable IDE/CLI binding to OLP.
|
||||
|
||||
## Quick setup
|
||||
|
||||
Three knobs, all environment variables or `.env`:
|
||||
|
||||
```bash
|
||||
# Required: point Aider at OLP's chat-completions endpoint
|
||||
export OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
|
||||
# Required: OLP plaintext token
|
||||
export OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Then invoke Aider with an OLP-routable model, prefixed `openai/`:
|
||||
aider --model openai/claude-sonnet-4-5
|
||||
```
|
||||
|
||||
The `openai/` prefix tells Aider to use its OpenAI-compatible adapter for
|
||||
the named model. Aider's litellm layer parses this and sends the request
|
||||
to whatever `OPENAI_API_BASE` resolves to.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=aider`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Aider's `.env` support
|
||||
|
||||
Aider auto-loads a `.env` file from the current directory or the git repo
|
||||
root. The accepted keys are:
|
||||
|
||||
```bash
|
||||
# .env at the project root
|
||||
OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Optional: Aider's own AIDER_-prefixed equivalents work too
|
||||
AIDER_OPENAI_API_BASE=http://127.0.0.1:4567/v1
|
||||
```
|
||||
|
||||
The `AIDER_` prefix wins over the bare prefix when both are set. Pick one;
|
||||
mixing them invites surprises during debugging.
|
||||
|
||||
**Hygiene:** add `.env` to `.gitignore` if your repo doesn't already. The
|
||||
OLP token is plaintext-recoverable from disk only because the chat surface
|
||||
explicitly opts into it (see [ADR 0011](../adr/0011-anonymous-key-deployment-context.md))
|
||||
— do not let your IDE bind unintentionally.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **No custom-headers support.** Aider does not expose a way to set extra
|
||||
HTTP headers on outgoing requests. OLP's optional `X-OLP-Chain` /
|
||||
`X-OLP-Bypass-Cache` headers are therefore not available via Aider —
|
||||
routing is determined by the model name alone.
|
||||
|
||||
- **`/v1` trailing matters.** `OPENAI_API_BASE` must end at `/v1` (without
|
||||
`/chat/completions`); Aider appends the remainder. Setting it to the bare
|
||||
host or with a trailing `/chat/completions` causes 404s.
|
||||
|
||||
- **Aider sends `max_tokens` by default.** OLP forwards `max_tokens` to
|
||||
every provider. If you see "model X does not support max_tokens" errors,
|
||||
the underlying provider rejects it — check `X-OLP-Provider-Used` and
|
||||
filter that provider out of the chain for the affected model.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Aider's request shape is faithful to OpenAI's `/v1/chat/completions`
|
||||
spec — `messages`, `model`, `max_tokens`, `stream`, `temperature`,
|
||||
`tools`. All map cleanly into OLP's IR with no lossy-translation warnings.
|
||||
|
||||
For long-context work (codebase summaries, large diffs), set
|
||||
`streaming.heartbeat_interval_ms: 15000` in `~/.olp/config.json` (see
|
||||
[README § Environment Variables](../../README.md#configjson-keys-introduced-at-phase-4))
|
||||
so the SSE stream stays alive through reverse proxies during silent
|
||||
windows.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
# In a scratch dir:
|
||||
aider --model openai/claude-haiku-4-5 --no-stream --message "say ok"
|
||||
```
|
||||
|
||||
Then check OLP's audit log:
|
||||
|
||||
```bash
|
||||
npx olp logs 5
|
||||
```
|
||||
|
||||
The most recent entry should show `provider: anthropic` (or whatever
|
||||
provider haiku routes to in your chain) and `cache_status: miss`.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Aider model config docs: https://aider.chat/docs/llms/openai-compat.html
|
||||
- [`olp-connect`](../../bin/olp-connect) writes `~/.aider/.env` if Aider is
|
||||
detected on PATH.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Claude Code + OLP
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) is
|
||||
Anthropic's official terminal-native agent. It speaks the Anthropic
|
||||
`/v1/messages` wire format and cannot be configured to use an
|
||||
OpenAI-compatible chat-completions endpoint.
|
||||
|
||||
**Status:** ❌ Not supported.
|
||||
|
||||
## Why
|
||||
|
||||
OLP serves only the OpenAI `/v1/chat/completions` wire format. Adding
|
||||
`/v1/messages` (the Anthropic shape) was explicitly considered for
|
||||
Phase 4 and rejected, per
|
||||
[ADR 0010 § Out of Phase 4 scope](../adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
The short version of the rationale:
|
||||
|
||||
- **No billing benefit.** After Anthropic's 2026-06-15 split, `claude -p` /
|
||||
Agent SDK / third-party agent traffic moves out of the Pro/Max
|
||||
subscription pool and into a separate paid Agent SDK Credit pool. OLP's
|
||||
fallback discipline ("when one provider's quota runs out, try the next")
|
||||
does not save money for this traffic — it just routes the same paid
|
||||
request to a different paid backend. The subscription leverage that
|
||||
makes OLP valuable for OpenAI-shape traffic does not exist for
|
||||
Anthropic-shape traffic.
|
||||
|
||||
- **Degrades worse on fallback.** When OLP's primary chain entry (Anthropic)
|
||||
is exhausted, the fallback hop is typically OpenAI Codex or Mistral Vibe.
|
||||
Those providers speak OpenAI tool-calling schema; OLP would have to
|
||||
translate Anthropic's `/v1/messages` tool shape into OpenAI tool shape on
|
||||
every fallback. That translation is lossy and is what ADR 0010 calls out
|
||||
as "net non-positive under P0 failure".
|
||||
|
||||
- **Same outcome reachable via the recommended alternative.** Cline, Cursor,
|
||||
Aider, and Continue.dev all speak OpenAI's wire format and have parity
|
||||
with Claude Code on the "AI edits files in my repo" use case. OLP serves
|
||||
them today.
|
||||
|
||||
## What to use instead
|
||||
|
||||
**Recommended:** [Cline](./cline.md). It's an in-IDE autonomous coder that
|
||||
operates on the same loop Claude Code does (read files, propose edits,
|
||||
run tools, iterate). The "OpenAI Compatible" provider points cleanly at
|
||||
OLP's `/v1/chat/completions` endpoint. You get OLP's full fallback chain
|
||||
(Anthropic → OpenAI Codex → Mistral) instead of being pinned to one
|
||||
provider.
|
||||
|
||||
For terminal users specifically:
|
||||
|
||||
- **[Aider](./aider.md)** if you want the Claude-Code-style git-aware
|
||||
pair programmer in the terminal.
|
||||
- **OpenClaw** if you want Telegram/Discord-driven access to the
|
||||
fallback chain (see [`openclaw.md`](./openclaw.md)).
|
||||
|
||||
## Re-open trigger
|
||||
|
||||
ADR 0010 documents the conditions under which OLP would reconsider
|
||||
`/v1/messages`:
|
||||
|
||||
> (a) ADR 0009 P0 confirms interactive-mode billing classification as
|
||||
> subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens
|
||||
> Phase 5 "Anthropic-shape hub" scope with the name of at least one
|
||||
> family member who wants CC access.
|
||||
|
||||
Until both conditions fire, OLP intentionally does not implement
|
||||
`/v1/messages`. The decision is recorded in ADR 0010 § "Out of Phase 4
|
||||
scope" and ADR 0009 (Anthropic interactive-mode path placeholder).
|
||||
|
||||
## If you absolutely must use Claude Code
|
||||
|
||||
Point Claude Code at api.anthropic.com directly. OLP cannot proxy that
|
||||
traffic. You will:
|
||||
|
||||
- Burn against the Anthropic Pro/Max OAuth subscription (pre-2026-06-15) or
|
||||
the Agent SDK Credit pool (≥ 2026-06-15).
|
||||
- Lose every fallback property OLP provides — when Anthropic's quota is
|
||||
exhausted, Claude Code stops working until the quota resets.
|
||||
- Lose OLP's response headers (`X-OLP-Provider-Used` etc.), audit log
|
||||
entries, cache hits, and `/health` visibility.
|
||||
|
||||
This is documented here only so the trade-off is explicit, not as a
|
||||
recommendation.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) § "Out of Phase 4 scope" — full defer rationale.
|
||||
- [ADR 0009](../adr/0009-interactive-mode-path-placeholder.md) — Anthropic 2026-06-15 billing split and re-open trigger.
|
||||
- [`cline.md`](./cline.md) — the recommended alternative for Claude-Code-style workflows.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Cline + OLP
|
||||
|
||||
[Cline](https://github.com/cline/cline) is an autonomous-coder VS Code
|
||||
extension. It speaks OpenAI's `/v1/chat/completions` wire format via its
|
||||
"OpenAI Compatible" provider option.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Cline v3.x (extension version visible in VS Code's
|
||||
extension panel). Cline's settings UI has shipped multiple variants of the
|
||||
base-URL field across 2025-2026; if your version doesn't show the field
|
||||
described below, see the Known Issues section.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Open the Cline panel in VS Code (sidebar icon).
|
||||
2. Click the settings gear → "API Provider".
|
||||
3. Select **OpenAI Compatible**.
|
||||
4. Fill the fields:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Base URL | `http://127.0.0.1:4567/v1` |
|
||||
| API Key | `olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` |
|
||||
| Model ID | `claude-sonnet-4-5` |
|
||||
|
||||
5. Save. Cline shows the model name in the bottom-right corner of the panel.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=cline`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Cline issue [#7128](https://github.com/cline/cline/issues/7128) —
|
||||
base-URL UI field intermittently disappears.** Several Cline releases in
|
||||
2025-2026 shipped a settings UI where the "Base URL" field is hidden when
|
||||
the "OpenAI Compatible" provider is freshly selected. Workaround: switch
|
||||
to a different provider, save, switch back to "OpenAI Compatible" — the
|
||||
field returns. Verify the field is visible in your version BEFORE
|
||||
troubleshooting OLP itself.
|
||||
|
||||
- **Cline writes settings to `.vscode/settings.json` under a
|
||||
`cline.apiConfiguration` key (workspace-scoped) and to the VS Code global
|
||||
state (machine-scoped) depending on the "save to workspace" toggle.** If
|
||||
Cline keeps "forgetting" the OLP base URL across VS Code restarts, the
|
||||
workspace state is overriding the global state. Either save to workspace
|
||||
explicitly, or clear the workspace key and use global state.
|
||||
|
||||
- **Cline sometimes lowercases the model ID before sending.** OLP's
|
||||
`models-registry.json` uses canonical case (e.g. `claude-sonnet-4-5`).
|
||||
This is fine — OLP's router lowercases the requested model for chain
|
||||
lookup. But if you see `unknown model` errors, double-check the exact
|
||||
string Cline sent via the OLP response headers (curl test below).
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Cline does not expose a custom-headers field in its OpenAI Compatible
|
||||
provider UI as of v3.x. The OLP routing chain is selected purely from the
|
||||
model ID — pick the canonical name (e.g. `claude-sonnet-4-5`) that matches
|
||||
a `routing.chains` key in your `~/.olp/config.json`.
|
||||
|
||||
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`,
|
||||
`X-OLP-Latency-Ms`) are not visible in Cline's UI but are captured by VS
|
||||
Code's Developer Tools Network panel when Cline runs the request.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
# 1. Verify OLP accepts Cline-shape requests
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5,"stream":false}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider serves
|
||||
sonnet in your chain) and `X-OLP-Cache: miss` on first request.
|
||||
|
||||
## Why not /v1/messages?
|
||||
|
||||
Cline supports Anthropic-shape requests via a separate "Anthropic" provider
|
||||
in its UI. OLP does not implement `/v1/messages`. Use Cline's **OpenAI
|
||||
Compatible** option pointed at OLP rather than Cline's **Anthropic** option
|
||||
pointed at api.anthropic.com — the OLP chain gives you fallback to OpenAI
|
||||
Codex / Mistral / etc. when the Anthropic subscription hits its quota
|
||||
ceiling. See [ADR 0010 § /v1/messages defer rationale](../adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Cline issue tracker: https://github.com/cline/cline/issues
|
||||
- [`olp-connect`](../../bin/olp-connect) automates writing the Cline workspace
|
||||
state.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Continue.dev + OLP
|
||||
|
||||
[Continue.dev](https://continue.dev) is an open-source autocomplete +
|
||||
chat extension for VS Code and JetBrains IDEs. It speaks OpenAI's
|
||||
`/v1/chat/completions` wire format, so it works against OLP with no
|
||||
shim layer.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
**Tested against:** Continue.dev v0.10.x (`config.yaml` schema). The
|
||||
older `config.json` schema (≤ v0.8) is **not** documented here — Continue
|
||||
deprecated it in late 2025 and emits a one-shot migration warning.
|
||||
|
||||
## Quick setup
|
||||
|
||||
Edit `~/.continue/config.yaml` (or open the Continue config from the IDE's
|
||||
extension panel and paste this in):
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: olp-chat
|
||||
provider: openai
|
||||
model: claude-sonnet-4-5
|
||||
apiBase: http://127.0.0.1:4567/v1
|
||||
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
roles:
|
||||
- chat
|
||||
requestOptions:
|
||||
headers:
|
||||
# Optional: pin which routing chain key applies. If omitted, OLP
|
||||
# looks up the chain via the model name above.
|
||||
X-OLP-Chain: claude-sonnet-4-5
|
||||
- name: olp-autocomplete
|
||||
provider: openai
|
||||
model: claude-haiku-4-5
|
||||
apiBase: http://127.0.0.1:4567/v1
|
||||
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
roles:
|
||||
- autocomplete
|
||||
```
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=continue-dev`. Family members on the LAN should substitute the OLP
|
||||
host's IP for `127.0.0.1` (or use `olp-connect <ip>` to do this for them
|
||||
automatically).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **`apiBase`, NOT `baseURL`.** Continue's YAML schema uses `apiBase` (no
|
||||
`URL` casing). The older `config.json` `baseURL` key was renamed during the
|
||||
v0.10 schema cut. If you copy a snippet from a 2024 blog post and it
|
||||
silently routes to api.openai.com, this is why.
|
||||
- **Trailing `/v1` matters.** OLP's chat-completions endpoint is at
|
||||
`/v1/chat/completions`; Continue appends `/chat/completions` to whatever
|
||||
`apiBase` resolves to. Set `apiBase: http://host:4567/v1` (with `/v1`),
|
||||
not the bare host.
|
||||
- **Provider stays `openai`.** Continue's `provider: anthropic` would send
|
||||
Anthropic-shape requests to `/v1/messages`, which OLP does not implement
|
||||
(see [`claude-code.md`](./claude-code.md) for the rationale).
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Continue's `requestOptions.headers` lets you pin OLP-specific routing
|
||||
behaviour without altering the model name itself. Useful headers:
|
||||
|
||||
- `X-OLP-Chain: <chain-key>` — explicitly select the routing chain.
|
||||
- `X-OLP-Bypass-Cache: true` — force a fresh spawn for the next request
|
||||
(debugging cache-poisoning suspicions).
|
||||
|
||||
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`, etc.) are
|
||||
visible via VS Code's `Developer: Toggle Developer Tools` → Network panel
|
||||
when Continue runs the request.
|
||||
|
||||
## Test it
|
||||
|
||||
After config save, open the Continue chat panel and send a one-word
|
||||
message ("ok"). Then on the terminal:
|
||||
|
||||
```bash
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider your chain
|
||||
routes haiku to) and `X-OLP-Cache: miss` on first request, `hit` on the
|
||||
second.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Continue.dev config reference: https://docs.continue.dev/customization/models
|
||||
- [`olp-connect`](../../bin/olp-connect) automates the Continue.dev branch
|
||||
of this setup.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Cursor + OLP
|
||||
|
||||
[Cursor](https://cursor.com) is an AI-first VS Code fork. It has an
|
||||
"Override OpenAI Base URL" setting that, when populated, routes its
|
||||
default-model traffic to your URL using the OpenAI wire format.
|
||||
|
||||
**Status:** ⚠️ Best-effort.
|
||||
|
||||
**Reason:** Cursor's base-URL override is known to be fragile across
|
||||
releases. Multiple 2025-2026 forum threads document the setting silently
|
||||
reverting, model-list dropdowns not populating from the override URL, and
|
||||
streaming responses falling back to the default backend on parse errors.
|
||||
The behaviour is not specific to OLP — every OpenAI-compatible proxy
|
||||
maintainer documents the same caveats — but Cursor's release cadence is
|
||||
faster than most third-party proxies can test against.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Open Cursor → Settings → "Models" → enable **OpenAI API Key**.
|
||||
2. Paste your OLP plaintext token into the **API Key** field.
|
||||
3. Click "Override OpenAI Base URL" and paste:
|
||||
|
||||
```
|
||||
http://127.0.0.1:4567/v1
|
||||
```
|
||||
|
||||
4. Click "Verify". Cursor sends a probe; on success the indicator turns
|
||||
green.
|
||||
|
||||
5. **Crucial step:** in the model list, disable every model that is NOT
|
||||
in your `~/.olp/config.json` `routing.chains`. Cursor's chat will round-
|
||||
robin across enabled models and any model OLP can't route will error.
|
||||
|
||||
Replace the API key with the plaintext token printed by `olp-keys keygen
|
||||
--name=cursor`. Family members on the LAN should substitute the OLP host's
|
||||
IP for `127.0.0.1` (or use `olp-connect <ip>`).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Override URL silently reverts on Cursor update.** Two reported variants:
|
||||
(a) the field empties; (b) the field shows the OLP URL but Cursor still
|
||||
hits api.openai.com under the hood. Workaround: after every Cursor
|
||||
update, re-open settings, click "Verify" again, and check the OLP
|
||||
server's `/health` for incoming probe requests.
|
||||
|
||||
- **Model-list dropdown does not populate from the override URL.** Cursor
|
||||
hardcodes its model list rather than reading `GET /v1/models`. This is
|
||||
why step 5 above is required — there is no way to make Cursor "discover"
|
||||
your models. You have to disable each model individually that OLP can't
|
||||
serve.
|
||||
|
||||
- **Streaming response parsing is stricter than OpenAI's actual SSE spec.**
|
||||
Cursor occasionally falls back to the default backend if the SSE stream
|
||||
contains a slightly malformed chunk (e.g. an empty `data:` line that
|
||||
OpenAI's API does emit but Cursor's parser doesn't expect). OLP's SSE
|
||||
emitter follows the spec; this is on Cursor's side. If you see traffic
|
||||
hitting api.openai.com despite the override, this is the most likely
|
||||
cause.
|
||||
|
||||
- **Cursor's "Tab" autocomplete is NOT covered by the override.** Tab
|
||||
completion uses a Cursor-proprietary endpoint that is not affected by the
|
||||
OpenAI base URL setting. Only the chat panel is. This is documented
|
||||
Cursor behaviour and is not a bug.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
Cursor sends `model: "gpt-4"` or `model: "gpt-3.5-turbo"` (legacy aliases)
|
||||
unless you explicitly select another from its dropdown. Add aliases to
|
||||
your `~/.olp/config.json` `routing.chains` so these route somewhere sane:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"chains": {
|
||||
"gpt-4": [ { "provider": "openai", "model": "gpt-5" } ],
|
||||
"gpt-3.5-turbo": [ { "provider": "openai", "model": "gpt-5-mini" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Substitute the OpenAI Codex model names listed by `olp models`.)
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Do not engineer workarounds for Cursor-side bugs.** Cursor's release
|
||||
cadence will fix or re-break the override URL handling at unpredictable
|
||||
intervals. If your daily-driver flow is unreliable, switch to Cline (see
|
||||
[`cline.md`](./cline.md)) — it has a stable OpenAI-compatible provider
|
||||
that does not break across releases.
|
||||
|
||||
## Test it
|
||||
|
||||
```bash
|
||||
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer olp_XXXXXX" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-4","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
|
||||
| grep -i x-olp
|
||||
```
|
||||
|
||||
After hitting "Send" in Cursor's chat, check the OLP server's recent
|
||||
requests via:
|
||||
|
||||
```bash
|
||||
npx olp logs 10
|
||||
```
|
||||
|
||||
If you don't see Cursor's request in the audit log, traffic isn't reaching
|
||||
OLP — re-check the override URL.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Cursor forum threads on base-URL fragility: https://forum.cursor.com/ (search "OpenAI base URL")
|
||||
- [`olp-connect`](../../bin/olp-connect) writes Cursor's `cursorrc` if
|
||||
detected, but cannot guarantee the override survives a Cursor update.
|
||||
@@ -0,0 +1,146 @@
|
||||
# OpenClaw + OLP
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is a multi-bot gateway
|
||||
that exposes slash commands on Telegram, Discord, and other chat
|
||||
surfaces. OLP ships [`olp-plugin/`](../../olp-plugin/) as a native
|
||||
OpenClaw plugin that registers a `/olp` slash command with read-only
|
||||
parity to the local `olp` CLI.
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
|
||||
## What you get
|
||||
|
||||
After install, from Telegram or Discord:
|
||||
|
||||
| Slash command | Maps to | Tier |
|
||||
|---|---|---|
|
||||
| `/olp status` | GET `/v0/management/status` | owner |
|
||||
| `/olp health` | GET `/health` | public |
|
||||
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
|
||||
| `/olp models` | GET `/v1/models` | public |
|
||||
| `/olp cache` | GET `/cache/stats` | owner |
|
||||
| `/olp providers` | local registry view | public |
|
||||
| `/olp chain show [model]` | local chain view | public |
|
||||
| `/olp doctor` | informational (HTTP endpoint not yet shipped) | — |
|
||||
| `/olp help` | usage text | — |
|
||||
|
||||
**Mutating subcommands are deliberately not exposed via chat.** `keygen`,
|
||||
`revoke`, `restart`, `logs` are SSH-only. See
|
||||
[`olp-plugin/README.md`](../../olp-plugin/README.md#what-you-can-not-do-from-chat-by-design)
|
||||
for the rationale.
|
||||
|
||||
## Quick setup
|
||||
|
||||
### 1. Install the plugin
|
||||
|
||||
Two install paths — either works.
|
||||
|
||||
**Option A — OpenClaw CLI:**
|
||||
|
||||
```bash
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
```
|
||||
|
||||
**Option B — symlink:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
### 2. Mint a bot owner key
|
||||
|
||||
Run on the OLP host (NOT in chat):
|
||||
|
||||
```bash
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
```
|
||||
|
||||
Capture the printed plaintext token — it is shown exactly once.
|
||||
|
||||
### 3. Configure
|
||||
|
||||
Edit `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Restart the gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
The plugin is now active. Try `/olp help` in your bot's chat.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **`openclaw gateway restart` is required after install.** OpenClaw caches
|
||||
plugin discovery at gateway start. `openclaw plugins reload` does not
|
||||
guarantee a fresh import of the plugin module.
|
||||
|
||||
- **Owner key revocation kicks the plugin out immediately.** If you revoke
|
||||
the bot's owner key (`npx olp-keys revoke --id=<id>`), the next `/olp
|
||||
status` will return `401 unauthorized`. Mint a replacement key with a
|
||||
new name and edit `~/.openclaw/openclaw.json`; do NOT reuse the revoked
|
||||
key's UUID.
|
||||
|
||||
- **Long responses are truncated.** Telegram caps messages at ~4096
|
||||
characters. The plugin truncates with a `... [truncated, use SSH for
|
||||
full]` suffix when the rendered output would exceed ~3900 chars. Use
|
||||
SSH + the local `olp` CLI for full output.
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
The plugin honours these env vars on the OpenClaw gateway process:
|
||||
|
||||
- `OLP_PROXY_URL` — full URL, overrides plugin config `proxyUrl`.
|
||||
- `OLP_PORT` — port only, localhost assumed; overrides `proxyUrl` when
|
||||
`OLP_PROXY_URL` is unset.
|
||||
|
||||
If you run the OpenClaw gateway under launchd or systemd with custom env
|
||||
vars, set `OLP_PROXY_URL` there rather than editing the plugin config —
|
||||
that way the same plugin install can serve multiple OLP hosts.
|
||||
|
||||
## Per-bot vs maintainer key
|
||||
|
||||
**Always create a dedicated bot key**, never the maintainer's personal
|
||||
owner key. The bot key:
|
||||
|
||||
- Has its own `id` so you can revoke it without affecting other clients.
|
||||
- Has its own audit-log entries so you can attribute `/v0/management/*`
|
||||
traffic to the bot.
|
||||
- Can be rotated routinely (every 90 days etc.) without coordinating with
|
||||
the maintainer's daily-driver IDE configs.
|
||||
|
||||
## Test it
|
||||
|
||||
After restart, in Telegram or Discord:
|
||||
|
||||
```
|
||||
/olp health
|
||||
/olp status
|
||||
/olp models
|
||||
```
|
||||
|
||||
Each should return a code-block-wrapped response within a few seconds.
|
||||
|
||||
If you see `401 unauthorized`: the configured key is missing / wrong /
|
||||
revoked. If you see `403 forbidden`: the key is not owner-tier. If you
|
||||
see `OLP error: fetch failed` or similar: the `proxyUrl` is unreachable
|
||||
from the gateway host (test with `curl http://<proxyUrl>/health` from
|
||||
that host).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`olp-plugin/README.md`](../../olp-plugin/README.md) — full plugin docs.
|
||||
- [ADR 0010 § Phase 4 D71-D73](../adr/0010-phase-4-charter-operator-and-client-ux.md) — the plugin's charter.
|
||||
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor (includes mutating subcommands that OLP deliberately drops).
|
||||
@@ -0,0 +1,153 @@
|
||||
# olp-plugin
|
||||
|
||||
OpenClaw gateway plugin that exposes a `/olp` slash command on Telegram and
|
||||
Discord, with subcommand parity to the local `olp` CLI (`bin/olp.mjs`) minus
|
||||
mutating operations.
|
||||
|
||||
**Authority:** [ADR 0010 § Phase 4 D71-D73](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md).
|
||||
|
||||
## Status
|
||||
|
||||
✅ Shipped at v0.4.0 (read-only subset of `olp` CLI).
|
||||
|
||||
## What you can do from chat
|
||||
|
||||
| Slash command | Maps to | Tier |
|
||||
|---|---|---|
|
||||
| `/olp status` | GET `/v0/management/status` | owner |
|
||||
| `/olp health` | GET `/health` | public |
|
||||
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
|
||||
| `/olp models` | GET `/v1/models` | public |
|
||||
| `/olp cache` | GET `/cache/stats` | owner |
|
||||
| `/olp providers` | local registry view | public |
|
||||
| `/olp chain show [model]` | local chain view (empty unless wired) | public |
|
||||
| `/olp doctor` | informational only (HTTP doctor endpoint not yet shipped) | — |
|
||||
| `/olp help` | usage text | — |
|
||||
|
||||
## What you can NOT do from chat (by design)
|
||||
|
||||
The following `olp` CLI subcommands are **deliberately not** ported to the
|
||||
chat surface, because Telegram + Discord are shared / persistent message
|
||||
streams and key material or raw audit logs should not be flowing across
|
||||
them:
|
||||
|
||||
- `olp keys keygen` — key material would land in chat history
|
||||
- `olp keys revoke` — accidental misclick could lock out clients
|
||||
- `olp restart` — a misclick should not cycle the proxy
|
||||
- `olp logs` — audit content may carry PII
|
||||
|
||||
Use SSH to the host running OLP and the local `olp` CLI for those.
|
||||
|
||||
## Install
|
||||
|
||||
The plugin is shipped inside the OLP repo at `olp-plugin/`. Two install paths:
|
||||
|
||||
### Option A — OpenClaw CLI
|
||||
|
||||
```bash
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
```
|
||||
|
||||
### Option B — symlink
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
Either path makes the plugin discoverable; restart the gateway to pick it up:
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Edit `~/.openclaw/openclaw.json` and add a config block for the `olp` plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `proxyUrl` — full URL of the OLP proxy. Default `http://127.0.0.1:4567`
|
||||
(OLP's default since v0.4.0 / D60). Overridable via `OLP_PROXY_URL` or
|
||||
`OLP_PORT` env if you run the gateway under launchd / systemd with custom
|
||||
env.
|
||||
- `apiKey` — **owner-tier** OLP API key. Required for the subcommands marked
|
||||
`owner` in the table above. Create one with:
|
||||
|
||||
```bash
|
||||
# On the OLP host, NOT in chat:
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
# Capture the plaintext token from the output — it is printed exactly once.
|
||||
```
|
||||
|
||||
Use a dedicated bot key (the `--name=openclaw-bot` example above) so you
|
||||
can `npx olp-keys revoke --id=<id>` later without affecting the
|
||||
maintainer's personal key.
|
||||
|
||||
## Use
|
||||
|
||||
In Telegram or Discord, after the gateway picks up the plugin:
|
||||
|
||||
```
|
||||
/olp status
|
||||
/olp usage
|
||||
/olp models
|
||||
/olp help
|
||||
```
|
||||
|
||||
Output is wrapped in a monospace code block. Long responses are truncated
|
||||
to fit Telegram's ~4096-char per-message limit; a `... [truncated, use SSH
|
||||
for full]` suffix marks where the cut happened.
|
||||
|
||||
## Authorization model
|
||||
|
||||
The plugin sends `Authorization: Bearer <apiKey>` on every request. OLP's
|
||||
server enforces:
|
||||
|
||||
- **public-tier endpoints** (`/health`, `/v1/models`) accept any non-revoked
|
||||
key (or no key at all if `auth.allow_anonymous: true`).
|
||||
- **owner-tier endpoints** (`/v0/management/*`, `/cache/stats`) reject any
|
||||
non-owner key with 403.
|
||||
|
||||
If you see `401 unauthorized` or `403 forbidden` in chat:
|
||||
|
||||
- Verify the configured `apiKey` is a non-revoked **owner**-tier key.
|
||||
- Verify the key was created on the same host running the OLP server (keys
|
||||
are stored under `~/.olp/keys/` and validated by hash on the server side).
|
||||
- Check the OLP server's `/health` directly with `curl` to confirm
|
||||
reachability.
|
||||
|
||||
## Port resolution priority
|
||||
|
||||
1. `OLP_PROXY_URL` env (full URL) — useful when the gateway runs on a
|
||||
different host than OLP and you proxy in via Tailscale.
|
||||
2. `OLP_PORT` env (port only; localhost assumed).
|
||||
3. Plugin config `proxyUrl`.
|
||||
4. Fallback `http://127.0.0.1:4567`.
|
||||
|
||||
## Why no Telegram/Discord SDK dependency
|
||||
|
||||
OpenClaw provides the transport (Telegram bot + Discord bot are gateway
|
||||
features). This plugin only registers a slash command — it does not open
|
||||
its own websocket / long-poll connection. That means:
|
||||
|
||||
- No new npm dependency.
|
||||
- No bot tokens stored in plugin config.
|
||||
- Plugin works for any OpenClaw-supported chat surface (currently Telegram +
|
||||
Discord; future surfaces inherit automatically).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [Local `olp` CLI](../bin/olp.mjs) — the full mutating-capable surface.
|
||||
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor this is ported from.
|
||||
- [ADR 0010](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter.
|
||||
- [ADR 0007](../docs/adr/0007-multi-key-auth.md) — multi-key auth model that gates owner-tier subcommands.
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* OLP Plugin — registers /olp as a native slash command in the OpenClaw gateway.
|
||||
* Calls the local OLP proxy and formats the response for Telegram/Discord.
|
||||
*
|
||||
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
|
||||
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
|
||||
* to the OLP namespace with two structural differences:
|
||||
*
|
||||
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
|
||||
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
|
||||
* shared / persistent surfaces; rotating an owner key or pulling raw audit
|
||||
* logs from a chat client is a security regression. Use SSH + the local
|
||||
* `olp` CLI for those operations.
|
||||
*
|
||||
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
|
||||
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
|
||||
* OLP API key with owner_tier="owner". The plugin config carries that key;
|
||||
* operators are advised to mint a dedicated bot key (NOT the maintainer's
|
||||
* personal owner key) so revocation is scoped.
|
||||
*
|
||||
* Port resolution (in priority order):
|
||||
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
|
||||
* 2. OLP_PORT env (port only; localhost assumed)
|
||||
* 3. Plugin config `proxyUrl`
|
||||
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
|
||||
*
|
||||
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
|
||||
* mutating operations. Mapping table is in ./README.md.
|
||||
*/
|
||||
|
||||
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
|
||||
|
||||
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
|
||||
export function mono(text) {
|
||||
return "```\n" + text + "\n```";
|
||||
}
|
||||
|
||||
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
|
||||
export function bar(pct, width = 16) {
|
||||
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
|
||||
const filled = Math.round(p * width);
|
||||
return "█".repeat(filled) + "░".repeat(width - filled);
|
||||
}
|
||||
|
||||
/** Status icon — used in `/olp status` summary lines. */
|
||||
export function statusIcon(status) {
|
||||
if (status === "ok" || status === true) return "🟢";
|
||||
if (status === "degraded" || status === "warn") return "🟡";
|
||||
return "🔴";
|
||||
}
|
||||
|
||||
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
|
||||
export function truncateForChat(text, maxChars = 3900) {
|
||||
if (text.length <= maxChars) return text;
|
||||
const SUFFIX = "\n... [truncated, use SSH for full]";
|
||||
// Reserve room for the suffix so the final string is <= maxChars.
|
||||
const room = Math.max(0, maxChars - SUFFIX.length);
|
||||
return text.slice(0, room) + SUFFIX;
|
||||
}
|
||||
|
||||
// ── Proxy URL resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the proxy base URL. Order: OLP_PROXY_URL env → OLP_PORT env →
|
||||
* plugin config `proxyUrl` → default http://127.0.0.1:4567.
|
||||
*
|
||||
* Exported for test injection. Pass `env` to override `process.env` and
|
||||
* `config` to override the plugin config block.
|
||||
*/
|
||||
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
|
||||
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
|
||||
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
|
||||
if (config.proxyUrl) return config.proxyUrl;
|
||||
return "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
// ── HTTP helper ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
|
||||
*
|
||||
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
|
||||
*/
|
||||
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
|
||||
const headers = {};
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
const resp = await fetchFn(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Subcommand formatters ──────────────────────────────────────────────────
|
||||
//
|
||||
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
|
||||
// string. The dispatcher fetches + delegates. This split makes the formatters
|
||||
// unit-testable without an HTTP mock.
|
||||
|
||||
export function fmtStatus(body) {
|
||||
const icon = statusIcon(body.ok ? "ok" : "fail");
|
||||
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
|
||||
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
|
||||
if (body.providers?.status && typeof body.providers.status === "object") {
|
||||
for (const [name, s] of Object.entries(body.providers.status)) {
|
||||
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
|
||||
}
|
||||
}
|
||||
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
|
||||
const c = body.stats?.cache;
|
||||
if (c) {
|
||||
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
|
||||
}
|
||||
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||
out += `\nRecent errors (${body.recent_errors.length}):\n`;
|
||||
for (const e of body.recent_errors.slice(0, 3)) {
|
||||
const ts = (e.time || "").slice(11, 19);
|
||||
const msg = String(e.message ?? "").slice(0, 60);
|
||||
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtHealth(body) {
|
||||
const icon = statusIcon(body.ok ? "ok" : "fail");
|
||||
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
|
||||
if (body.uptime_human || body.uptimeHuman) {
|
||||
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
|
||||
}
|
||||
if (body.providers && typeof body.providers === "object") {
|
||||
out += `\nProviders:\n`;
|
||||
for (const [name, s] of Object.entries(body.providers)) {
|
||||
if (typeof s !== "object" || s === null) continue;
|
||||
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||
out += ` ${i} ${name}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtUsage(body) {
|
||||
let out = "OLP usage (24h)\n";
|
||||
out += "─────────────────────────────\n";
|
||||
const w = body.window_24h ?? body.usage_24h ?? {};
|
||||
if (w.requests !== undefined) {
|
||||
out += `Requests: ${w.requests}\n`;
|
||||
out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`;
|
||||
out += `Fallbacks: ${w.fallbacks ?? "?"}\n`;
|
||||
} else if (typeof body.cache_hit_24h === "number") {
|
||||
// Dashboard-data shape: cache_hit_24h is a rate ∈ [0,1]
|
||||
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
|
||||
}
|
||||
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
out += `\nPer-provider quota:\n`;
|
||||
for (const q of body.quota) {
|
||||
const pct = typeof q.percent_used === "number" ? q.percent_used : null;
|
||||
const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api";
|
||||
out += ` ${String(q.name ?? "?").padEnd(10)}${bar0}\n`;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
|
||||
out += `\nTop fallback chains (24h):\n`;
|
||||
for (const f of body.top_fallback_chains_24h.slice(0, 5)) {
|
||||
out += ` ${String(f.count ?? "?").padStart(5)} ${(f.chain ?? []).join(" → ")}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtModels(body) {
|
||||
const data = body.data ?? [];
|
||||
if (data.length === 0) return "No models.";
|
||||
let out = `Models (${data.length})\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const m of data) {
|
||||
out += ` ${m.id}${m.owned_by ? ` (${m.owned_by})` : ""}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtCache(body) {
|
||||
let out = "OLP cache\n";
|
||||
out += "─────────────────────────────\n";
|
||||
out += `Entries: ${body.size ?? body.entries ?? "?"}\n`;
|
||||
out += `Hits: ${body.hits ?? 0}\n`;
|
||||
out += `Misses: ${body.misses ?? 0}\n`;
|
||||
out += `Inflight: ${body.inflightCount ?? 0}\n`;
|
||||
if (typeof body.evictions === "number") {
|
||||
out += `Evictions: ${body.evictions}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtProviders(registry, configEnabled) {
|
||||
const providers = registry?.providers ?? {};
|
||||
const names = Object.keys(providers);
|
||||
let out = `OLP providers (${names.length} in registry)\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const name of names) {
|
||||
const p = providers[name];
|
||||
const enabled = configEnabled?.[name] === true ? "enabled " : "disabled";
|
||||
const tier = p?.tier ?? "?";
|
||||
const modelCount = (p?.models ?? []).length;
|
||||
const candidate = p?.candidate === true ? " (candidate)" : "";
|
||||
out += ` ${name.padEnd(10)} ${enabled} tier ${tier} models ${String(modelCount).padStart(2)}${candidate}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtChainShow(chains, target) {
|
||||
if (!chains || Object.keys(chains).length === 0) {
|
||||
return "No chains configured.";
|
||||
}
|
||||
if (target) {
|
||||
const chain = chains[target];
|
||||
if (!chain) {
|
||||
return `Model "${target}" not in routing.chains.\nConfigured: ${Object.keys(chains).join(", ")}`;
|
||||
}
|
||||
let out = `${target}:\n`;
|
||||
for (const hop of chain) {
|
||||
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let out = "OLP routing.chains\n";
|
||||
out += "─────────────────────────────\n";
|
||||
for (const [model, chain] of Object.entries(chains)) {
|
||||
out += `${model}:\n`;
|
||||
for (const hop of chain) {
|
||||
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtDoctor(body) {
|
||||
// body shape: { checks, fail_count, warn_count, ok_count, kind, summary, next_action }
|
||||
let out = `OLP doctor — ${body.summary ?? "?"}\n`;
|
||||
out += "─────────────────────────────\n";
|
||||
for (const c of (body.checks ?? []).slice(0, 20)) {
|
||||
const icon = c.status === "ok" ? "🟢" : c.status === "warn" ? "🟡" : "🔴";
|
||||
out += ` ${icon} ${String(c.id ?? "?").padEnd(34)} ${String(c.message ?? "").slice(0, 60)}\n`;
|
||||
}
|
||||
if ((body.checks ?? []).length > 20) {
|
||||
out += ` ... (${body.checks.length - 20} more — use SSH 'olp doctor' for full output)\n`;
|
||||
}
|
||||
out += `\nfail=${body.fail_count ?? 0} warn=${body.warn_count ?? 0} ok=${body.ok_count ?? 0} kind=${body.kind ?? "?"}\n`;
|
||||
if (body.next_action?.ai_executable?.length > 0) {
|
||||
out += `\nNext (AI-executable):\n`;
|
||||
for (const cmd of body.next_action.ai_executable.slice(0, 5)) {
|
||||
out += ` $ ${cmd}\n`;
|
||||
}
|
||||
}
|
||||
if (body.next_action?.human_required?.length > 0) {
|
||||
out += `\nNext (human-required):\n`;
|
||||
for (const step of body.next_action.human_required.slice(0, 5)) {
|
||||
out += ` • ${step}\n`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Help text ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function cmdHelp() {
|
||||
return `OLP Commands (read-only)
|
||||
─────────────────────────────
|
||||
/olp status Process + provider + cache snapshot
|
||||
/olp health /health endpoint (public-ok)
|
||||
/olp usage 24h request stats + per-provider quota
|
||||
/olp models Available models
|
||||
/olp cache Cache stats
|
||||
/olp providers Provider registry + enabled flags
|
||||
/olp chain show [model] Routing chain(s) from server config
|
||||
/olp doctor Diagnostic checks + suggested next action
|
||||
/olp help This message
|
||||
|
||||
Mutating commands (keygen / revoke / restart / logs) are NOT
|
||||
available from chat by design — use SSH + the local 'olp' CLI.`;
|
||||
}
|
||||
|
||||
// ── Dispatcher ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure subcommand dispatcher. Returns `{ text }` always — caller wraps in
|
||||
* mono() for the chat surface.
|
||||
*
|
||||
* Exported for unit tests. Injects:
|
||||
* - fetchFn (default global fetch)
|
||||
* - proxyUrl (resolved upstream so tests can pin)
|
||||
* - apiKey (from plugin config)
|
||||
* - registry (models-registry.json — caller provides since this module
|
||||
* ships in `olp-plugin/` and the file is a sibling concept living at
|
||||
* the repo root)
|
||||
* - chainsLocal (local routing.chains override — usually empty; the
|
||||
* server-side /v0/management/status already exposes provider+chain
|
||||
* state, but chain-show is the one local-config touch that mirrors
|
||||
* `olp chain show`)
|
||||
*/
|
||||
export async function dispatch(rawArgs, opts) {
|
||||
const {
|
||||
proxyUrl,
|
||||
apiKey,
|
||||
registry,
|
||||
chainsLocal = {},
|
||||
fetchFn = fetch,
|
||||
} = opts;
|
||||
|
||||
const raw = (rawArgs || "").trim();
|
||||
const spaceIdx = raw.indexOf(" ");
|
||||
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
|
||||
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
|
||||
|
||||
try {
|
||||
switch (subcmd) {
|
||||
case "status": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v0/management/status`, { apiKey, fetchFn });
|
||||
return { text: fmtStatus(body) };
|
||||
}
|
||||
case "health": {
|
||||
const body = await fetchJSON(`${proxyUrl}/health`, { apiKey, fetchFn });
|
||||
return { text: fmtHealth(body) };
|
||||
}
|
||||
case "usage": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v0/management/dashboard-data`, { apiKey, fetchFn });
|
||||
return { text: fmtUsage(body) };
|
||||
}
|
||||
case "models": {
|
||||
const body = await fetchJSON(`${proxyUrl}/v1/models`, { apiKey, fetchFn });
|
||||
return { text: fmtModels(body) };
|
||||
}
|
||||
case "cache": {
|
||||
const body = await fetchJSON(`${proxyUrl}/cache/stats`, { apiKey, fetchFn });
|
||||
return { text: fmtCache(body) };
|
||||
}
|
||||
case "providers": {
|
||||
// models-registry.json + (optionally) the server's idea of which are
|
||||
// enabled. /v0/management/status carries that and is owner-gated, but
|
||||
// /v1/models lists what's exposed publicly. For the chat surface we
|
||||
// use the public registry shape — config.enabled is a local-config
|
||||
// concept and the plugin doesn't have filesystem access to
|
||||
// ~/.olp/config.json by design.
|
||||
return { text: fmtProviders(registry, {}) };
|
||||
}
|
||||
case "chain": {
|
||||
// /olp chain show [model]
|
||||
const inner = subargs.trim();
|
||||
const parts = inner.split(/\s+/).filter(Boolean);
|
||||
if (parts[0] !== "show") {
|
||||
return { text: `Usage: /olp chain show [model]` };
|
||||
}
|
||||
const target = parts[1] ?? null;
|
||||
return { text: fmtChainShow(chainsLocal, target) };
|
||||
}
|
||||
case "doctor": {
|
||||
// /v0/management/doctor doesn't exist yet — D67 added doctor as a CLI
|
||||
// surface only. The plugin reports that explicitly so families know
|
||||
// to use SSH + `olp doctor` rather than waiting for a chat response.
|
||||
return {
|
||||
text: `/olp doctor is not yet wired through HTTP (planned for Phase 5+).\n` +
|
||||
`Run \`olp doctor\` over SSH on the host running the OLP server\n` +
|
||||
`for the full diagnostic output.`,
|
||||
};
|
||||
}
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "":
|
||||
return { text: cmdHelp() };
|
||||
default:
|
||||
return { text: `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return { text: `OLP error: ${err.message ?? String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin entry point (consumed by OpenClaw gateway) ──────────────────────
|
||||
|
||||
/**
|
||||
* OpenClaw plugin entry. The gateway calls this with its `api` registration
|
||||
* object; we register the `/olp` slash command and a handler that resolves
|
||||
* the proxy URL + API key from plugin config + env, then delegates to
|
||||
* `dispatch()`.
|
||||
*
|
||||
* The `registry` (models-registry.json) is read lazily inside the handler
|
||||
* so that a stale plugin install doesn't bind to an old snapshot — and so
|
||||
* the plugin module stays import-time-pure for tests.
|
||||
*/
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "olp",
|
||||
description: "OLP — usage, health, status, doctor, etc. (read-only)",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const cfg = ctx.config ?? {};
|
||||
const apiKey = cfg.apiKey ?? process.env.OLP_API_KEY ?? null;
|
||||
const proxyUrl = resolveProxyUrl({ env: process.env, config: cfg });
|
||||
// Lazy load to avoid binding the import to the OpenClaw gateway's
|
||||
// ESM cache (which may pre-resolve at plugin-discovery time).
|
||||
let registry;
|
||||
try {
|
||||
// Convert file path to URL for ESM `import(...)`.
|
||||
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
||||
const { dirname, resolve: pathResolve } = await import("node:path");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const registryUrl = pathToFileURL(pathResolve(here, "..", "models-registry.json")).href;
|
||||
registry = (await import(registryUrl, { with: { type: "json" } })).default;
|
||||
} catch (e) {
|
||||
registry = { providers: {} };
|
||||
}
|
||||
// Local chains config is not currently surfaced through HTTP. For
|
||||
// chat-side chain-show we fall back to an empty map; operators
|
||||
// wanting the live config view should use `olp chain show` over SSH.
|
||||
const chainsLocal = {};
|
||||
const { text } = await dispatch(ctx.args ?? "", {
|
||||
proxyUrl,
|
||||
apiKey,
|
||||
registry,
|
||||
chainsLocal,
|
||||
});
|
||||
return { text: mono(truncateForChat(text)) };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "olp",
|
||||
"name": "OLP Commands",
|
||||
"description": "Slash commands for OLP — /olp status, /olp usage, /olp health, etc. (read-only by design; mutations require SSH).",
|
||||
"version": "0.4.0",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"proxyUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:4567",
|
||||
"description": "Full URL of the OLP proxy. Default matches D60 OLP_PORT=4567. Overridable via OLP_PROXY_URL or OLP_PORT env."
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Owner-tier OLP API key (olp_xxx). Required for owner-only subcommands (status / usage / cache). Use a dedicated bot key — DO NOT share the maintainer's personal owner key."
|
||||
}
|
||||
},
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "olp-plugin",
|
||||
"version": "0.4.0",
|
||||
"description": "OpenClaw gateway plugin — /olp slash commands for the OLP proxy (read-only)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"keywords": ["openclaw", "plugin", "olp", "proxy"],
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"openclaw": {
|
||||
"type": "plugin",
|
||||
"id": "olp",
|
||||
"pluginManifest": "openclaw.plugin.json"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user