mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
## F4 — bin/olp.mjs + olp-plugin/index.js migrate to quota_v2 Codex post-v0.5.0 review Q4: both `olp usage` CLI and `/olp usage` plugin handler read legacy `body.quota` shape, which never has `percent_used` or meaningful `available` data → display always fell through to "no quota api" even when anthropic live quota was visible on the dashboard (D81/D82). Authority: codex review Q4 (PR #58); ADR 0008 Amendment 2 (quota_v2 shape: { provider, status, utilization, reset, representative_claim, fallback_percentage, overage, failure?, failure_kind?, backoff_until? }). ### bin/olp.mjs cmdUsage - When `body.quota_v2` present (non-empty array, server v0.5.0+): render per-provider rows with status badge (live/stale/unreachable/unavailable), 5h + 7d utilization % (color-coded: green <50% / yellow 50-80% / red ≥80%), reset countdowns, binding claim, ⚠ stale / ❌ unreachable annotations. - When `body.quota_v2` absent: fall through to existing legacy `body.quota` rendering (preserves backwards compat with pre-v0.5.0 servers). - Added `formatResetCountdown(epochSeconds)` — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from dashboard.html D82. Exported for tests. Kept in bin/olp.mjs (not lib/) per spec guidance. - Added `formatAgo(diffMs)` — small helper for stale-row age display. ### olp-plugin/index.js fmtUsage() - Same quota_v2 migration. Plain-text output (no ANSI), one line per provider. Legacy body.quota fallback preserved. - Added `pluginFormatResetCountdown(epochSeconds)` — intentionally duplicated from bin/olp.mjs (olp-plugin ships as a separate package and must not import from bin/). Exported for tests. - Also fixed fmtUsage to read `w.request_count` (dashboard-data shape) in addition to legacy `w.requests` (OCP-era shape) for completeness. ### Backwards compat - Pre-v0.5.0 server returns body.quota only → both surfaces use legacy display (unchanged behaviour). - v0.5.0+ server returns both quota and quota_v2 → both surfaces prefer quota_v2. - Legacy code paths kept (5-10 lines each, not deleted). ## v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — CLOSED The dedicated test was already shipped at D56 (test-features.mjs line 6255: 'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'). This commit closes the roadmap entry with a date stamp and PR reference. Authority: docs/v1x-roadmap.md § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)". ## Tests Suite 40 (9 new tests — 40a through 40i): 40a — cmdUsage parses quota_v2 live rows (mock server) 40b — cmdUsage falls back to legacy body.quota when quota_v2 absent 40c — olp-plugin fmtUsage parses quota_v2 live row 40d — olp-plugin fmtUsage falls back to legacy body.quota 40e — formatResetCountdown covers all 5 time ranges 40f — pluginFormatResetCountdown covers past/<1h/<24h/<7d/≥7d 40g — cmdUsage renders quota_v2 stale row with ⚠ stale note 40h — cmdUsage renders quota_v2 unreachable row with ❌ indicator 40i — cmdUsage renders quota_v2 unavailable rows correctly 759 → 768 tests, 0 fail. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
533 lines
22 KiB
JavaScript
533 lines
22 KiB
JavaScript
/**
|
|
* OLP Plugin — registers /olp as a native slash command in the OpenClaw gateway.
|
|
* Calls the local OLP proxy and formats the response for Telegram/Discord.
|
|
*
|
|
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
|
|
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
|
|
* to the OLP namespace with two structural differences:
|
|
*
|
|
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
|
|
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
|
|
* shared / persistent surfaces; rotating an owner key or pulling raw audit
|
|
* logs from a chat client is a security regression. Use SSH + the local
|
|
* `olp` CLI for those operations.
|
|
*
|
|
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
|
|
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
|
|
* OLP API key with owner_tier="owner". The plugin config carries that key;
|
|
* operators are advised to mint a dedicated bot key (NOT the maintainer's
|
|
* personal owner key) so revocation is scoped.
|
|
*
|
|
* Port resolution (in priority order):
|
|
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
|
|
* 2. OLP_PORT env (port only; localhost assumed)
|
|
* 3. Plugin config `proxyUrl`
|
|
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
|
|
*
|
|
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
|
|
* mutating operations. Mapping table is in ./README.md.
|
|
*/
|
|
|
|
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
|
|
|
|
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
|
|
export function mono(text) {
|
|
return "```\n" + text + "\n```";
|
|
}
|
|
|
|
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
|
|
export function bar(pct, width = 16) {
|
|
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
|
|
const filled = Math.round(p * width);
|
|
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
}
|
|
|
|
/** Status icon — used in `/olp status` summary lines. */
|
|
export function statusIcon(status) {
|
|
if (status === "ok" || status === true) return "🟢";
|
|
if (status === "degraded" || status === "warn") return "🟡";
|
|
return "🔴";
|
|
}
|
|
|
|
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
|
|
export function truncateForChat(text, maxChars = 3900) {
|
|
if (text.length <= maxChars) return text;
|
|
const SUFFIX = "\n... [truncated, use SSH for full]";
|
|
// Reserve room for the suffix so the final string is <= maxChars.
|
|
const room = Math.max(0, maxChars - SUFFIX.length);
|
|
return text.slice(0, room) + SUFFIX;
|
|
}
|
|
|
|
// ── Proxy URL resolution ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the proxy base URL. Order: OLP_PROXY_URL env → OLP_PORT env →
|
|
* plugin config `proxyUrl` → default http://127.0.0.1:4567.
|
|
*
|
|
* Exported for test injection. Pass `env` to override `process.env` and
|
|
* `config` to override the plugin config block.
|
|
*/
|
|
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
|
|
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
|
|
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
|
|
if (config.proxyUrl) return config.proxyUrl;
|
|
return "http://127.0.0.1:4567";
|
|
}
|
|
|
|
// ── HTTP helper ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
|
|
*
|
|
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
|
|
*/
|
|
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
|
|
const headers = {};
|
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
const resp = await fetchFn(url, {
|
|
headers,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (resp.status === 401) {
|
|
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
|
|
}
|
|
if (resp.status === 403) {
|
|
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
|
|
}
|
|
if (!resp.ok) {
|
|
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
// ── Subcommand formatters ──────────────────────────────────────────────────
|
|
//
|
|
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
|
|
// string. The dispatcher fetches + delegates. This split makes the formatters
|
|
// unit-testable without an HTTP mock.
|
|
|
|
export function fmtStatus(body) {
|
|
const icon = statusIcon(body.ok ? "ok" : "fail");
|
|
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
|
|
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
|
|
if (body.providers?.status && typeof body.providers.status === "object") {
|
|
for (const [name, s] of Object.entries(body.providers.status)) {
|
|
const i = statusIcon(s?.ok ? "ok" : "fail");
|
|
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
|
|
}
|
|
}
|
|
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
|
|
const c = body.stats?.cache;
|
|
if (c) {
|
|
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
|
|
}
|
|
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
|
out += `\nRecent errors (${body.recent_errors.length}):\n`;
|
|
for (const e of body.recent_errors.slice(0, 3)) {
|
|
const ts = (e.time || "").slice(11, 19);
|
|
const msg = String(e.message ?? "").slice(0, 60);
|
|
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function fmtHealth(body) {
|
|
const icon = statusIcon(body.ok ? "ok" : "fail");
|
|
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
|
|
if (body.uptime_human || body.uptimeHuman) {
|
|
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
|
|
}
|
|
// D74 P2-4 fix: server.mjs /health full payload is
|
|
// body.providers = { enabled: N, available: N, status: { <name>: {...} } }
|
|
// The plugin previously iterated Object.entries(body.providers), which
|
|
// surfaced `enabled`, `available`, and `status` as pseudo-providers
|
|
// (typeof status === 'object' → loop body fired with name='status').
|
|
// Walk providers.status when present; fall back to providers.* for the
|
|
// older OCP shape that lacks the .status wrapper.
|
|
if (body.providers && typeof body.providers === "object") {
|
|
const enabled = body.providers.enabled;
|
|
const available = body.providers.available;
|
|
if (typeof enabled === "number" || typeof available === "number") {
|
|
out += `Providers: ${enabled ?? "?"} enabled / ${available ?? "?"} available\n`;
|
|
}
|
|
const statusMap = body.providers.status && typeof body.providers.status === "object"
|
|
? body.providers.status
|
|
: body.providers;
|
|
const entries = Object.entries(statusMap).filter(
|
|
([name, s]) => typeof s === "object" && s !== null && name !== "enabled" && name !== "available" && name !== "status"
|
|
);
|
|
if (entries.length > 0) {
|
|
out += `\nProviders:\n`;
|
|
for (const [name, s] of entries) {
|
|
const i = statusIcon(s?.ok ? "ok" : "fail");
|
|
const spawn = typeof s?.activeSpawns === "number" ? ` spawns=${s.activeSpawns}` : "";
|
|
out += ` ${i} ${name}${spawn}\n`;
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* formatResetCountdown(epochSeconds) → human-readable reset countdown.
|
|
*
|
|
* Mirrors bin/olp.mjs + dashboard.html versions. Five ranges:
|
|
* past / < 1h / < 24h / < 7d / ≥ 7d
|
|
*
|
|
* Authority: ADR 0008 Amendment 2 (quota_v2 shape), ported from dashboard.html (D82).
|
|
* No external deps. Duplicated here intentionally (olp-plugin ships separately).
|
|
*/
|
|
export function pluginFormatResetCountdown(epochSeconds) {
|
|
if (epochSeconds == null) return "—";
|
|
const nowMs = Date.now();
|
|
const targetMs = epochSeconds * 1000;
|
|
const diffMs = targetMs - nowMs;
|
|
if (diffMs <= 0) return "resetting now";
|
|
const diffMin = Math.floor(diffMs / 60000);
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
const diffDay = Math.floor(diffHr / 24);
|
|
if (diffMin < 60) return `resets in ${diffMin}m`;
|
|
if (diffHr < 24) {
|
|
const remMin = diffMin - diffHr * 60;
|
|
if (remMin === 0) return `resets in ${diffHr}h`;
|
|
return `resets in ${diffHr}h ${remMin}m`;
|
|
}
|
|
const target = new Date(targetMs);
|
|
const timeStr = target.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
if (diffDay < 7) {
|
|
const dayStr = target.toLocaleString("en-US", { weekday: "short" });
|
|
return `resets ${dayStr} ${timeStr}`;
|
|
}
|
|
const dateStr = target.toLocaleString("en-US", { month: "short", day: "numeric" });
|
|
return `resets ${dateStr} ${timeStr}`;
|
|
}
|
|
|
|
export function fmtUsage(body) {
|
|
let out = "OLP usage (24h)\n";
|
|
out += "─────────────────────────────\n";
|
|
const w = body.window_24h ?? body.usage_24h ?? {};
|
|
if (w.request_count !== undefined) {
|
|
out += `Requests: ${w.request_count}\n`;
|
|
const c = body.cache_hit_24h ?? {};
|
|
if (typeof c.hit_rate === "number") {
|
|
out += `Cache hit: ${(c.hit_rate * 100).toFixed(1)}%\n`;
|
|
}
|
|
} else 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") {
|
|
// Legacy: cache_hit_24h as a bare number
|
|
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
|
|
}
|
|
|
|
// F4: prefer quota_v2 when present (server v0.5.0+), fall back to legacy quota.
|
|
// Authority: ADR 0008 Amendment 2 (quota_v2 shape).
|
|
if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) {
|
|
out += `\nPer-provider quota (live):\n`;
|
|
for (const p of body.quota_v2) {
|
|
const name = String(p.provider ?? "?").toUpperCase().padEnd(10);
|
|
const status = p.status ?? "unavailable";
|
|
if (status === "unavailable") {
|
|
out += ` ${name} unavailable ${p.reason ?? "no public quota api"}\n`;
|
|
} else if (status === "unreachable") {
|
|
const fk = p.failure?.kind ?? "unknown";
|
|
out += ` ${name} no cached data — failure: ${fk}\n`;
|
|
} else {
|
|
// live or stale
|
|
const util = p.utilization ?? {};
|
|
const reset = p.reset ?? {};
|
|
const parts = [];
|
|
for (const window of ["5h", "7d"]) {
|
|
const frac = util[window];
|
|
const resetEpoch = reset[window];
|
|
if (frac != null) {
|
|
const pct = `${Math.round(frac * 100)}%`;
|
|
const rst = pluginFormatResetCountdown(resetEpoch);
|
|
parts.push(`${window}: ${pct} (${rst})`);
|
|
}
|
|
}
|
|
const staleNote = status === "stale"
|
|
? ` ⚠ stale (${p.failure?.kind ?? "unknown"})`
|
|
: "";
|
|
out += ` ${name} ${status.padEnd(6)} ${parts.join(" ")}${staleNote}\n`;
|
|
}
|
|
}
|
|
} else if (Array.isArray(body.quota) && body.quota.length > 0) {
|
|
// Legacy fallback for pre-v0.5.0 servers
|
|
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.provider ?? 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)) };
|
|
},
|
|
});
|
|
}
|