diff --git a/docs/plugins/PLUGIN_DEVELOPMENT.md b/docs/plugins/PLUGIN_DEVELOPMENT.md new file mode 100644 index 000000000000..b341d413a25d --- /dev/null +++ b/docs/plugins/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,522 @@ +--- +title: "Plugin Development Guide" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# Plugin Development Guide + +> **TL;DR**: This guide covers the **day-to-day workflow** for building, testing, signing, and debugging OmniRoute plugins. For the SDK API surface, see the [Plugin SDK Reference](./PLUGIN_SDK.md). For the marketplace, see [Plugin Marketplace](./PLUGIN_MARKETPLACE.md). + +--- + +## Overview + +OmniRoute's plugin system has **two layers** that work together: + +| Layer | Purpose | Where it runs | +|-------|---------|---------------| +| **SDK plugins** | Hook-based request/response interception | In-process sandboxed VM (the OmniRoute server) | +| **CLI plugins** | Command-based CLI extensions | Separate Node.js process (the `omniroute` CLI) | + +This guide is about **SDK plugins** — the in-process kind that hook into every request. For CLI plugins, see [CLI Plugin System](#cli-plugin-system) below or [`docs/dev/plugins.md`](../dev/plugins.md). + +--- + +## Lifecycle: Install → Activate → Run → Deactivate → Uninstall + +Every plugin goes through a **5-stage lifecycle** managed by `PluginManager` (`src/lib/plugins/manager.ts`): + +``` + ┌─────────────┐ + │ Installed │ ← files on disk + manifest valid + └──────┬──────┘ + │ activate() + ▼ + ┌─────────────┐ + ┌──────────▶│ Activated │ ← hooks registered, can intercept requests + │ └──────┬──────┘ + │ deactivate() │ (running) + │ ▼ + │ ┌─────────────┐ + │ │ Hooks fire │ ← onRequest, onResponse, onError per request + │ └──────┬──────┘ + │ │ + │ deactivate() │ + └──────────────────┘ + │ uninstall() + ▼ + ┌─────────────┐ + │ Removed │ ← files deleted, DB row removed + └─────────────┘ +``` + +### Built-in Events + +Plugins can register handlers for the following built-in events via `definePlugin()`. See [PLUGIN_SDK.md](./PLUGIN_SDK.md#built-in-events) for the complete list of events and their payloads. + +The only events currently available to plugin developers are the three request-handling hooks on the `Plugin` interface (`src/lib/plugins/hooks.ts:249`): + +```ts +import { definePlugin } from "omniroute/plugins/sdk"; + +export default definePlugin({ + name: "my-plugin", + + // Core request-handling events (available now) + onRequest: async (ctx) => { + console.log(`Request: ${ctx.requestId}`); + }, + + onResponse: async (ctx, response) => { + console.log(`Response for: ${ctx.requestId}`); + return response; // optionally transform + }, + + onError: async (ctx, error) => { + console.error(`Error: ${error.message}`); + }, +}); +``` + +> **Note:** `BUILTIN_EVENTS` (`src/lib/plugins/hooks.ts:35`) also lists routing events (`onModelSelect`, `onComboResolve`, etc.), stream events (`onStreamStart`, `onStreamEnd`), and lifecycle events (`onInstall`, `onActivate`, `onDeactivate`, `onUninstall`). None of these are currently exposed on the `Plugin` interface or `definePlugin()`. Plugin developers should use `onRequest`/`onResponse`/`onError` for now. See the [Built-in Events](./PLUGIN_SDK.md#built-in-events) section in the SDK reference for details on which events are available vs. planned. +--- + +## Dev Mode: Hot Reload on File Changes (Not Yet Wired) + +> **⚠️ Not yet wired up.** `src/lib/plugins/devMode.ts` exports `startDevMode()` and `stopDevMode()`, but no caller invokes them. The environment variables `PLUGIN_DEV_MODE` and `OMNIROUTE_PLUGIN_DEV` are not read anywhere — `PLUGIN_DEV_MODE` is only used as a logger name (`src/lib/plugins/devMode.ts:12`). Dev mode is planned but not currently functional. + +**`src/lib/plugins/devMode.ts`** is designed to watch the plugin directory for file changes and automatically reload affected plugins. Once wired up, it will be the fastest way to iterate on a plugin during development. + +### Planned Behavior + +When implemented, dev mode would work as follows: + +1. A caller (e.g. server startup or CLI command) invokes `startDevMode(pluginDir, reloadFn)` +2. The watcher monitors `~/.omniroute/plugins/*` recursively +3. On any file change inside a plugin directory, it waits 500ms (debounce) +4. It calls `reloadFn(pluginName)` — typically `deactivate(pluginName)` then `activate(pluginName)` +5. Results are logged to the `PLUGIN_DEV_MODE` logger + +### How It Works (source code) + +```ts +// src/lib/plugins/devMode.ts +const DEBOUNCE_MS = 500; + +export function startDevMode(pluginDir: string, reloadFn: ReloadFn): void { + if (watcher) return; + + watcher = watch(pluginDir, { recursive: true }, (_eventType, filename) => { + if (!filename) return; + // Cross-platform: normalize backslashes before splitting + const pluginName = filename.replace(/\\/g, "/").split("/")[0]; + if (!pluginName || pluginName.startsWith(".")) return; + + // Debounce rapid changes + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + await reloadFn(pluginName); // deactivate + activate + }, DEBOUNCE_MS); + }); +} +``` + +### What Needs to Happen for Dev Mode to Work + +To make dev mode functional, the following would need to be implemented: +- A config flag or environment variable that triggers `startDevMode()` on server startup +- Integration with `PluginManager.deactivate()` + `PluginManager.activate()` as the reload function +- A `stopDevMode()` call on server shutdown + +Until then, plugin developers should restart the OmniRoute server manually after editing plugin files. +--- + +## Testing Plugins + +**`src/lib/plugins/testRunner.ts`** provides a quick way to verify that all your plugin's hooks work without standing up a full request flow. + +### Quick Test + +```ts +import { testPlugin } from "omniroute/plugins/testRunner"; +import { readFileSync } from "fs"; + +const manifest = JSON.parse(readFileSync("./plugin.json", "utf-8")); +const results = await testPlugin("./index.js", manifest); + +for (const r of results) { + console.log(`${r.passed ? "✓" : "✗"} ${r.hook} (${r.durationMs}ms)`); + if (!r.passed) console.error(` Error: ${r.error}`); +} +``` + +### What Gets Tested + +The test runner invokes **every registered hook** with a mock context: + +```ts +// src/lib/plugins/testRunner.ts +const MOCK_CONTEXT: PluginContext = { + requestId: "test-req-001", + body: { model: "gpt-4", messages: [{ role: "user", content: "test" }] }, + model: "gpt-4", + provider: "openai", + metadata: { test: true }, +}; +``` + +For each hook, the runner records: +- `passed` (boolean): did the hook complete without throwing? +- `durationMs` (number): how long the hook took +- `output` (unknown): the hook's return value +- `error` (string): if the hook threw, the error message + +### Sample Output + +```json +[ + { "hook": "onRequest", "passed": true, "durationMs": 3, "output": null }, + { "hook": "onResponse", "passed": true, "durationMs": 1, "output": { "choices": [...] } }, + { "hook": "onError", "passed": true, "durationMs": 0, "output": null } +] +``` + +### Limitations + +The test runner uses a **fixed mock context** — it cannot simulate: +- Streaming responses +- Multi-turn conversations +- Specific provider error formats +- Custom `PluginContext` shapes + +For full integration testing, use the [OmniRoute test harness](#integration-testing) in your own test suite. + +--- + +## Plugin Doctor: Diagnostic Health Checks + +**`src/lib/plugins/doctor.ts`** runs **5 health checks** on a plugin to help you diagnose common issues. + +### Running the Doctor + +The plugin doctor is available as a programmatic API only: + +```ts +// Programmatic API +import { runPluginDoctor } from "omniroute/plugins/doctor"; + +const result = await runPluginDoctor("~/.omniroute/plugins/my-plugin", "my-plugin"); +console.log(result); +``` + +Note: There is no CLI command for the doctor. To diagnose plugins, integrate `runPluginDoctor` into your plugin management tooling. + +### The 5 Checks + +| # | Check name | What it verifies | Status values | +|---|------------|------------------|---------------| +| 1 | `directory_exists` | Plugin directory is on disk | `pass` / `fail` | +| 2 | `manifest_valid` | `plugin.json` parses and matches schema | `pass` / `fail` | +| 3 | `entry_point_exists` | `manifest.main` file is on disk | `pass` / `fail` / `warn` | +| 4 | `can_spawn` | Entry point has `.js` or `.mjs` extension | `pass` / `warn` | +| 5 | `db_status_correct` | Plugin row in DB matches filesystem state | `pass` / `warn` | + +### Interpreting Results + +```ts +interface DoctorResult { + pluginName: string; + checks: DoctorCheck[]; + overall: "healthy" | "degraded" | "unhealthy"; +} +``` + +- **`healthy`** — all 5 checks passed +- **`degraded`** — some `warn` (e.g. manifest valid but entry point missing) +- **`unhealthy`** — any `fail` (e.g. manifest invalid) + +### Common Issues and Fixes + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| `directory_exists: fail` | Plugin was deleted from disk but still in DB | Run `omniroute plugin uninstall ` | +| `manifest_valid: fail` | `plugin.json` doesn't match the schema | Check the [manifest schema](./PLUGIN_SDK.md#manifest-pluginjson) | +| `entry_point_exists: fail` | `manifest.main` points to a non-existent file | Fix the `main` field in `plugin.json` | +| `can_spawn: warn` | Entry point has `.ts` or other extension | Compile to `.js` or `.mjs` first | +| `db_status_correct: warn` | Plugin not in DB (was just installed) | Re-run `omniroute plugin install ` | + +--- + +## Plugin Signing & Verification + +**`src/lib/plugins/signing.ts`** provides Ed25519-based code signing so users can verify that a plugin came from a trusted source. + +### What's Available + +```ts +// SHA-256 hashing +sha256(data: Buffer): string +verifySha256(data: Buffer, expectedHash: string): boolean + +// Ed25519 signature verification +verifyEd25519(data: Buffer, signature: Buffer, publicKeyDer: Buffer): boolean +``` + +### Why Sign Plugins? + +- **Trust**: Users can verify a plugin was published by a specific author +- **Integrity**: Detect if a plugin package was tampered with after publishing +- **Supply chain**: Build a chain of trust from author → registry → user + +### Verifying a Plugin (v3.8.16+) + +```ts +import { verifyEd25519, verifySha256 } from "omniroute/plugins/signing"; +import { readFileSync } from "fs"; + +const pluginBytes = readFileSync("./my-plugin.tar.gz"); +const signature = readFileSync("./my-plugin.sig"); +const publicKey = readFileSync("./author-pubkey.der"); + +if (verifyEd25519(pluginBytes, signature, publicKey)) { + console.log("✓ Plugin signature valid"); +} else { + throw new Error("Plugin signature invalid — do not install"); +} +``` + +### Generating a Signature (Author Side) + +```ts +import { createPrivateKey, sign, generateKeyPairSync } from "crypto"; +import { readFileSync, writeFileSync } from "fs"; + +// 1. Generate a key pair (one-time) +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +writeFileSync("./author-pubkey.der", publicKey.export({ format: "der", type: "spki" })); +writeFileSync("./author-privkey.der", privateKey.export({ format: "der", type: "pkcs8" })); + +// 2. Sign your plugin package +const data = readFileSync("./my-plugin.tar.gz"); +const key = createPrivateKey(readFileSync("./author-privkey.der")); +const signature = sign(null, data, key); +writeFileSync("./my-plugin.sig", signature); +``` + +### Registry Workflow (Recommended) + +``` + Author Registry User + │ │ │ + │ 1. Generate key pair │ │ + │ 2. Publish pubkey.der ───────────▶│ │ + │ 3. Publish plugin.tar.gz ────────▶│ Store pubkey │ + │ 4. Publish plugin.sig ───────────▶│ Verify signature │ + │ │ │ + │ │ 5. User requests install ◀───│ + │ │ 6. Ship plugin + signature │ + │ │ ────────────────────────────▶│ + │ │ │ 7. Verify signature +``` + +> **Note**: The remote registry with signature verification is planned for **Phase 2** of the marketplace. For now, signing is **advisory** — OmniRoute does not block installation of unsigned plugins, but it surfaces a warning. + +--- + +## CLI Plugin System + +OmniRoute's **CLI** is also extensible. CLI plugins add new subcommands to the `omniroute` binary, similar to `gh extension` or `kubectl plugin`. + +> For the full CLI plugin reference, see [`docs/dev/plugins.md`](../dev/plugins.md). Summary below. + +### Quick Example + +```bash +# Install a CLI plugin from npm +omniroute plugin install stripe + +# Install a local plugin in development +omniroute plugin install ./my-plugin + +# Scaffold a new plugin +omniroute plugin scaffold myplugin +cd omniroute-cmd-myplugin +omniroute plugin install . +``` + +### CLI Plugin Package Layout + +``` +omniroute-cmd-myplugin/ +├── package.json # name: "omniroute-cmd-", type: "module", main: "index.mjs" +├── index.mjs # exports register(program, ctx) + optional meta +└── README.md +``` + +### CLI Plugin Entry Point + +```js +export const meta = { + name: "myplugin", + version: "0.1.0", + description: "My plugin for OmniRoute", + omnirouteApi: ">=4.0.0", +}; + +export function register(program, ctx) { + program + .command("myplugin") + .description(meta.description) + .option("-n, --name ") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + const res = await ctx.apiFetch("/api/combos", { + baseUrl: gOpts.baseUrl, + apiKey: gOpts.apiKey, + }); + const data = await res.json(); + ctx.emit(data, gOpts); + }); +} +``` + +### CLI Plugin Context API + +| Property | Type | Description | +|----------|------|-------------| +| `ctx.apiFetch(path, opts)` | `async function` | Authenticated fetch to the OmniRoute server | +| `ctx.emit(data, opts)` | `function` | Output in `table`/`json`/`jsonl`/`csv` per `--output` flag | +| `ctx.t(key)` | `async function` | i18n translation lookup | +| `ctx.withSpinner(label, fn)` | `async function` | Wraps async fn with ora spinner | +| `ctx.baseUrl` | `string` | Resolved base URL | +| `ctx.apiKey` | `string \| null` | API key if provided | + +### CLI Plugin Discovery + +Plugins are discovered from: +1. `~/.omniroute/plugins//` — user-local installs +2. `OMNIROUTE_PLUGIN_PATH` env var — custom directory + +Loading errors are caught and printed as warnings — a broken plugin never crashes the CLI. + +### Security Warning + +CLI plugins run with the **same Node.js process privileges** as `omniroute`. Only install plugins from sources you trust. `omniroute plugin install` shows an explicit warning and requires `--yes` or interactive confirmation. + +--- + +## Best Practices + +### Plugin Structure + +``` +my-plugin/ +├── plugin.json # Manifest (required) +├── index.js # Entry point (or index.mjs, index.ts compiled) +├── README.md # User-facing docs +├── CHANGELOG.md # Version history +├── lib/ # Internal modules +│ ├── helpers.js +│ └── config.js +├── test/ # Tests +│ ├── plugin.test.js +│ └── mocks.js +└── examples/ # Usage examples + └── basic.js +``` + +### Versioning + +- Follow [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH` +- Bump `MAJOR` for breaking API changes +- Bump `MINOR` for new features (backward compatible) +- Bump `PATCH` for bug fixes +- Update `CHANGELOG.md` for every release + +### Error Handling + +Always handle errors gracefully — a thrown exception in a hook will block the request: + +```ts +import { definePlugin, addMetadata } from "omniroute/plugins/sdk"; + +export default definePlugin({ + name: "my-plugin", + onRequest: async (ctx) => { + try { + const result = await someExternalCall(ctx); + return addMetadata({ external: result }); + } catch (err) { + // Log but don't fail the request + console.error("External call failed:", err); + return addMetadata({ external: null, error: String(err) }); + } + }, +}); +``` + +### Performance + +- Keep hooks **fast** — they run on every request +- For expensive operations, use **caching** with TTL +- Use `Promise.all` for independent async work +- Avoid `setTimeout` / `setInterval` inside hooks + +### Permissions + +Request **only the permissions you need**: + +```json +{ + "requires": { + "permissions": ["network"] + } +} +``` + +Plugins without `network` permission cannot call `fetch` — the global is simply not available in the sandbox. + +--- + +## Troubleshooting + +### "Plugin not loading" + +Use the plugin doctor (programmatically) to diagnose: +```ts +import { runPluginDoctor } from "omniroute/plugins/doctor"; + +const result = await runPluginDoctor("~/.omniroute/plugins/my-plugin", "my-plugin"); +console.log(result); +``` + +Check the 5 checks. The most common cause is `manifest_valid: fail` — usually a missing required field. + +### "Hook not firing" + +1. Check the plugin is **activated** (not just installed): `omniroute plugin list` +2. Check the **event** in your manifest: `onRequest: { enabled: true, priority: 50 }` +3. Check the **priority**: a higher-priority plugin may be returning `blockRequest()` first + +### "Permission denied" inside a hook + +You tried to use a global that requires a permission you didn't request. Add it to `plugin.json`: +```json +{ + "requires": { "permissions": ["network", "file-read"] } +} +``` + +### "My plugin changes don't take effect" + +Dev mode is not yet wired up (see [Dev Mode section](#dev-mode-hot-reload-on-file-changes-not-yet-wired) above). Restart the OmniRoute server manually after editing plugin files. + +--- + +## What's Next? + +- **[Plugin SDK Reference](./PLUGIN_SDK.md)** — Full API surface, manifest schema, built-in events +- **[Plugin Marketplace](./PLUGIN_MARKETPLACE.md)** — Discover, install, and publish plugins +- **[CLI Plugin Reference](../dev/plugins.md)** — Extend the `omniroute` CLI +- **[Source code](../../src/lib/plugins/)** — Implementation reference (17 files in `src/lib/plugins/`) diff --git a/docs/plugins/PLUGIN_MARKETPLACE.md b/docs/plugins/PLUGIN_MARKETPLACE.md new file mode 100644 index 000000000000..d3feedf117eb --- /dev/null +++ b/docs/plugins/PLUGIN_MARKETPLACE.md @@ -0,0 +1,349 @@ +--- +title: "Plugin Marketplace" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# Plugin Marketplace + +> **TL;DR**: The marketplace is a curated registry of OmniRoute plugins. In **Phase 1** (current), it's a local seed registry. **Phase 2** will introduce a remote registry with ratings, downloads, and signature verification. + +**Source:** `src/lib/plugins/marketplace.ts` + +--- + +## Architecture + +``` +┌────────────────────────────────────────────┐ +│ Marketplace API │ +│ (listMarketplacePlugins, searchMarketplace│ +│ getMarketplaceEntry) │ +└────────────────┬───────────────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ + Phase 1 Phase 2 (planned) + ─────── ─────────────── + Local seed Remote registry + registry with ratings, + (3 entries) downloads, signed + packages +``` + +### Phase 1: Local Seed Registry (v3.8.16+) + +The local seed registry ships with **3 verified plugins** maintained by the OmniRoute team: + +| Plugin | Tags | Description | +|--------|------|-------------| +| `request-logger` | logging, debugging | Logs all requests and responses with timing | +| `rate-limiter` | rate-limit, security | Per-model rate limiting with sliding window | +| `cost-tracker` | analytics, cost | Track token costs per request and per model | + +All three are `verified: true` and `license: MIT`. + +### Phase 2: Remote Registry (Planned) + +The remote registry will add: +- **Author submissions** — anyone can publish a plugin +- **Ratings** — 0-5 stars from user reviews +- **Downloads** — install count +- **Signature verification** — Ed25519 signatures ensure authenticity +- **Search by tag, author, license** + +--- + +## API Reference + +All marketplace functions are exported from `omniroute/plugins/marketplace`. + +### `listMarketplacePlugins()` + +List all available plugins in the marketplace. + +```ts +import { listMarketplacePlugins } from "omniroute/plugins/marketplace"; + +const plugins = listMarketplacePlugins(); +for (const p of plugins) { + console.log(`${p.name}@${p.version} — ${p.description} (${p.rating}★)`); +} +``` + +**Returns:** `MarketplaceEntry[]` + +### `searchMarketplace(query)` + +Search marketplace plugins by name, description, or tags. + +```ts +import { searchMarketplace } from "omniroute/plugins/marketplace"; + +const loggingPlugins = searchMarketplace("logging"); +const securityPlugins = searchMarketplace("security"); +``` + +**Parameters:** +- `query` (string) — case-insensitive search string + +**Returns:** `MarketplaceEntry[]` — matches where `name`, `description`, or any `tag` contains the query + +### `getMarketplaceEntry(name)` + +Get a specific marketplace entry by name. + +```ts +import { getMarketplaceEntry } from "omniroute/plugins/marketplace"; + +const entry = getMarketplaceEntry("request-logger"); +if (entry) { + console.log(`Found: ${entry.name}@${entry.version}`); +} +``` + +**Parameters:** +- `name` (string) — exact plugin name + +**Returns:** `MarketplaceEntry | undefined` + +### `isMarketplaceAvailable()` + +Check if the marketplace is reachable. + +```ts +import { isMarketplaceAvailable } from "omniroute/plugins/marketplace"; + +if (isMarketplaceAvailable()) { + console.log("Marketplace is online"); +} +``` + +**Returns:** `boolean` — currently always `true` (local seed always available). Will reflect remote registry health in Phase 2. + +--- + +## Data Model + +### `MarketplaceEntry` + +```ts +interface MarketplaceEntry { + name: string; // e.g. "request-logger" + version: string; // e.g. "1.0.0" + description: string; // human-readable summary + author: string; // e.g. "omniroute", "your-name" + license: string; // SPDX license ID, e.g. "MIT" + downloadUrl: string; // URL to download the package + repository?: string; // optional source repo URL + tags: string[]; // search tags + downloads: number; // total install count + rating: number; // 0-5 star average + verified: boolean; // official verification flag + lastUpdated: string; // ISO date of last release +} +``` + +### Field Semantics + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | ✅ yes | Must be unique, kebab-case, match the plugin's `plugin.json` | +| `version` | ✅ yes | SemVer string | +| `description` | ✅ yes | One-line summary shown in lists | +| `author` | ✅ yes | Display name or org | +| `license` | ✅ yes | SPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.) | +| `downloadUrl` | ✅ yes | URL to the package tarball/zip | +| `repository` | ❌ no | Source code URL (recommended) | +| `tags` | ✅ yes | At least 1 tag for search | +| `downloads` | ✅ yes | Cumulative install count | +| `rating` | ✅ yes | 0.0 to 5.0, one decimal place | +| `verified` | ✅ yes | True if published by OmniRoute team or signed by trusted author | +| `lastUpdated` | ✅ yes | ISO 8601 date string | + +--- + +## Accessing the Marketplace + +The SDK marketplace is accessible **programmatically** via the marketplace API (`src/lib/plugins/marketplace.ts`), not through the CLI. + +### Programmatic Access + +```ts +import { listMarketplacePlugins, searchMarketplace } from "omniroute/plugins/marketplace"; + +// List all seed plugins +const allPlugins = await listMarketplacePlugins(); +console.log(allPlugins); // [request-logger, rate-limiter, cost-tracker] + +// Search by tag +const loggingPlugins = await searchMarketplace({ tags: ["logging"] }); +console.log(loggingPlugins); // [request-logger] + +// Search by name +const tracker = await searchMarketplace({ query: "cost" }); +console.log(tracker); // [cost-tracker] +``` + +### Note: CLI Plugin System is Separate + +The CLI command `omniroute plugin search` searches npm for **CLI plugins** (packages named `omniroute-cmd-*`), not the SDK marketplace. These are two separate plugin systems: +- **SDK plugins** (this doc): Hook-based request/response interception. Managed via the marketplace API. +- **CLI plugins** (separate system): Command-line extensions. Searched and installed via npm. + +--- + +## Discovering Plugins + +### Tag-Based Discovery + +Common tags used in the registry: + +| Tag | What it covers | +|-----|----------------| +| `logging` | Request/response logging, audit trails | +| `debugging` | Inspection, tracing, dev tools | +| `rate-limit` | Throttling, quota enforcement | +| `security` | Auth, IP filtering, PII masking | +| `analytics` | Usage tracking, cost monitoring | +| `cost` | Token pricing, budget guards | +| `transform` | Request/response rewriting | +| `cache` | Response caching, memoization | +| `provider` | Custom provider integrations | +| `combo` | Combo routing customizations | + +### Search Tips + +- **Multi-word queries**: `search("rate limit")` matches `rate-limit` in tags (hyphen-normalized) +- **Tag-only search**: `search("analytics")` returns plugins tagged `analytics` OR `cost` (loose match) +- **Author search**: not supported in Phase 1; will be added in Phase 2 + +--- + +## Publishing a Plugin (Phase 2) + +When the remote registry goes live, the publishing flow will be: + +### 1. Prepare Your Plugin + +- `plugin.json` manifest with all required fields +- Source code under `lib/` or `src/` +- Compiled entry point (`index.js` or `index.mjs`) +- `README.md` with usage examples +- `LICENSE` file +- `CHANGELOG.md` with version history + +### 2. Generate a Signature (Optional but Recommended) + +See [Plugin Signing & Verification](./PLUGIN_DEVELOPMENT.md#plugin-signing--verification) for the full signing workflow. + +### 3. Submit to the Registry + +```bash +# Once the remote registry is live +omniroute plugin publish \ + --tarball ./my-plugin-1.0.0.tar.gz \ + --signature ./my-plugin-1.0.0.sig \ + --public-key ./author-pubkey.der +``` + +The registry will: +1. Verify the signature against your public key +2. Run automated security scans (sandbox, no `eval`, no shell exec without permission) +3. Validate the manifest against the schema +4. Publish the entry with `verified: false` initially +5. Grant `verified: true` after a manual review window (typically 3-5 days) + +### 4. Versioning and Updates + +- Bump the version in `plugin.json` and `package.json` +- Update `CHANGELOG.md` +- Re-submit with the new tarball +- Users will be notified of available updates + +### 5. Ratings and Trust + +Users can rate installed plugins 1-5 stars. The aggregate rating is shown in the marketplace. Plugins with consistently low ratings may be flagged for review. + +--- + +## Verified Plugins + +The `verified: true` flag means the plugin is published by the **OmniRoute team** or by an author whose public key has been added to the trusted set. + +| Plugin | Author | Verified | License | Description | +|--------|--------|----------|---------|-------------| +| `request-logger` | omniroute | ✅ | MIT | Logs all requests and responses with timing | +| `rate-limiter` | omniroute | ✅ | MIT | Per-model rate limiting with sliding window | +| `cost-tracker` | omniroute | ✅ | MIT | Track token costs per request and per model | + +When the remote registry launches, third-party authors can apply for verification by: +1. Maintaining a public source repository +2. Signing all releases +3. Demonstrating active maintenance +4. Passing a security review + +--- + +## Ratings and Trust + +### Current State (Phase 1) + +- Seed plugins have initial ratings set by the OmniRoute team: `request-logger: 5`, `rate-limiter: 5`, `cost-tracker: 4` +- No user ratings yet +- All plugins are `verified: true` by definition + +### Phase 2 Plan + +- 1-5 star ratings from any user who installed the plugin +- Aggregate rating shown as `4.7★ (123 ratings)` style +- Trust tiers: + - **`verified: true`** — published by OmniRoute team OR signature verified + - **`verified: false`** — third-party plugin, not yet reviewed + - **`flagged`** — under security review, not installable + +### Quality Signals + +The marketplace UI will surface: +- Star rating with count +- Download count +- Last update date +- "Verified" badge +- Author reputation (Phase 2) +- Compatibility with current OmniRoute version + +--- + +## Migration Plan (Phase 1 → Phase 2) + +The current `MarketplaceEntry` shape is designed to be forward-compatible: + +| Field | Phase 1 (now) | Phase 2 (future) | +|-------|---------------|------------------| +| `downloadUrl` | Empty string for seed entries | Real tarball URLs | +| `downloads` | 0 for seed entries | Cumulative real count | +| `rating` | 5.0 for seed entries | Aggregated user ratings | +| `verified` | Always `true` for seed | Computed from signature + author | +| `lastUpdated` | Release date | Auto-updated on new version | + +**No breaking changes** are planned for the public API. New fields may be added. + +--- + +## Source Code Reference + +| File | Lines | Purpose | +|------|-------|---------| +| `src/lib/plugins/marketplace.ts` | 107 | Marketplace data + API | +| `src/lib/plugins/manager.ts` | 410+ | Install/activate/deactivate lifecycle | +| `src/lib/plugins/loader.ts` | 280+ | Plugin loading and validation | +| `src/lib/plugins/manifest.ts` | 200+ | Manifest schema validation | +| `src/lib/plugins/signing.ts` | 34 | SHA-256 + Ed25519 verification | + +--- + +## What's Next? + +- **[Plugin SDK Reference](./PLUGIN_SDK.md)** — Full API surface, manifest schema, built-in events +- **[Plugin Development Guide](./PLUGIN_DEVELOPMENT.md)** — Dev mode, testing, doctor, lifecycle +- **[Phase 2 Roadmap](#)** — Remote registry with ratings, downloads, signing (coming soon) diff --git a/docs/plugins/PLUGIN_SDK.md b/docs/plugins/PLUGIN_SDK.md index 6a81c05fba37..b7a8910e542d 100644 --- a/docs/plugins/PLUGIN_SDK.md +++ b/docs/plugins/PLUGIN_SDK.md @@ -1,5 +1,23 @@ # OmniRoute Plugin SDK +> **Related guides:** +> - [Plugin Development Guide](./PLUGIN_DEVELOPMENT.md) — dev mode, testing, doctor, signing, lifecycle +> - [Plugin Marketplace](./PLUGIN_MARKETPLACE.md) — discover, install, and publish plugins +> - [CLI Plugin System](../dev/plugins.md) — extend the `omniroute` CLI + +## Two Plugin Systems + +OmniRoute has **two parallel plugin systems** that serve different purposes: + +| System | Where it runs | Purpose | Reference | +|--------|---------------|---------|-----------| +| **SDK plugins** (this doc) | In-process sandboxed VM inside the OmniRoute server | Hook-based request/response interception (onRequest, onResponse, onError) | Below | +| **CLI plugins** | Separate Node.js process invoked by the `omniroute` binary | Add new subcommands to the CLI (like `gh extension` or `kubectl plugin`) | [CLI Plugin Reference](../dev/plugins.md) | + +You can use either or both. A typical setup might have: +- An **SDK plugin** that adds rate limiting to incoming requests +- A **CLI plugin** that exposes a `omniroute ratelimit status` command to inspect the rate limiter state + ## Quick Start ```ts @@ -41,7 +59,7 @@ Block the request and optionally return a custom response. ```ts onRequest: (ctx) => { - if (!ctx.headers["authorization"]) { + if (!ctx.apiKeyInfo) { return blockRequest({ error: "Unauthorized", status: 401 }); } }; @@ -75,9 +93,8 @@ onRequest: (ctx) => { | `model` | `string` | Requested model name | | `provider` | `string` | Target provider ID | | `body` | `Record` | Request body | -| `headers` | `Record` | Request headers | +| `apiKeyInfo` | `unknown` | API key info (if authenticated) | | `metadata` | `Record` | Mutable metadata | -| `timestamp` | `number` | Request timestamp | ## Manifest (`plugin.json`) @@ -167,11 +184,22 @@ Config values are persisted in the database and accessible via the dashboard con ## Built-in Events +### Available to plugin developers + +These are the events plugin developers can handle via `definePlugin()`. They correspond to the `onRequest`, `onResponse`, and `onError` fields on the [`Plugin` interface](./PLUGIN_DEVELOPMENT.md#built-in-events) (`src/lib/plugins/hooks.ts:249`). + | Event | When | Payload | |---|---|---| | `onRequest` | Before chat handler | Request context | | `onResponse` | After chat handler | Response data | | `onError` | On handler error | Error object | + +### Server-internal events (not registerable by plugins) + +The following events are declared in `BUILTIN_EVENTS` (`src/lib/plugins/hooks.ts:35`) and are emitted by the server internally. Plugin developers **cannot** register handlers for these via `definePlugin()` — the `Plugin` interface does not expose them. They may become available in a future release. + +| Event | When | Payload | +|---|---|---| | `onModelSelect` | Model selected for routing | Model info | | `onComboResolve` | Combo routing resolved | Combo targets | | `onRateLimit` | Rate limit hit | Limit info | @@ -180,7 +208,18 @@ Config values are persisted in the database and accessible via the dashboard con | `onStreamStart` | SSE stream started | Stream info | | `onStreamEnd` | SSE stream ended | Stream stats | -## Examples +### Lifecycle events (planned — not yet available to plugin developers) + +The following lifecycle events are declared in `BUILTIN_EVENTS` and `PluginManager` (`src/lib/plugins/manager.ts`) emits them during state transitions, but the typed `Plugin` interface and `definePlugin()` do **not** expose them. Plugin developers cannot currently handle these events. Use `onRequest`/`onResponse`/`onError` for now. + +| Event | When | Payload | +|---|---|---| +| `onInstall` | Plugin installed | `{ name, version, manifest }` | +| `onActivate` | Plugin activated | `{ name, version, manifest }` | +| `onDeactivate` | Plugin deactivated | `{ name, version, manifest }` | +| `onUninstall` | Plugin uninstalled | `{ name, version, manifest }` | + +> **Note:** The manifest schema (`plugin.json`) accepts `onInstall`, `onActivate`, `onDeactivate`, and `onUninstall` in the `hooks` field, and `PluginManager` reads these to decide whether to emit the corresponding events. However, `definePlugin()` does not pass lifecycle handlers through to the `Plugin` object, so plugin code cannot currently respond to these events. ### Request Logger @@ -190,7 +229,7 @@ import { definePlugin } from "omniroute/plugins/sdk"; export default definePlugin({ name: "request-logger", onRequest: async (ctx) => { - console.log(`[${new Date().toISOString()}] ${ctx.method} ${ctx.model} -> ${ctx.provider}`); + console.log(`[${new Date().toISOString()}] ${ctx.model} -> ${ctx.provider || "unknown"}`); }, }); ``` @@ -206,7 +245,7 @@ export default definePlugin({ name: "rate-limiter", priority: 10, onRequest: async (ctx) => { - const key = ctx.headers["x-api-key"] || "anonymous"; + const key = ctx.requestId || "anonymous"; const now = Date.now(); const window = 60000; // 1 minute const maxRequests = 100; diff --git a/src/lib/plugins/devMode.ts b/src/lib/plugins/devMode.ts index f7e75c22db44..56235fa12e53 100644 --- a/src/lib/plugins/devMode.ts +++ b/src/lib/plugins/devMode.ts @@ -27,7 +27,7 @@ export function startDevMode(pluginDir: string, reloadFn: ReloadFn): void { if (!filename) return; // Extract plugin name from path (first segment) - const pluginName = filename.split("/")[0]; + const pluginName = filename.replace(/\\/g, "/").split("/")[0]; if (!pluginName || pluginName.startsWith(".")) return; // Debounce rapid changes