From 191009dd23c1b08e98ddfe34283de43f50013051 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 29 May 2026 19:54:00 -0300 Subject: [PATCH] Release v3.8.7 (#2919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): WordPress-style plugin system backend * fix(plugins): address code review feedback - Path traversal guard: validate entryPoint stays within plugin dir - install() now handles direct plugin directories (not just parent dirs) - Non-null assertion replaced with explicit null check - require efficiency: allowedModules map moved outside function - Source wrapper: add newlines to prevent trailing comment issues - Config validation: validate values against configSchema on save - Dynamic import comment: clarify Node.js caching behavior Co-Authored-By: OpenClaude (mimo-v2.5-pro) * fix(plugins): replace vm with child_process, add auth to all routes Addresses all remaining code review feedback: 1. **Loader rewrite**: Replaced Node.js vm module with child_process.fork() for proper process-level isolation. Complies with Rule 3 (no eval). Each plugin runs in a separate Node.js process with IPC communication. 2. **Auth on all routes**: Added requireManagementAuth to all 6 plugin API route files (list, install, scan, details, activate, deactivate, config). 3. **Env filtering**: Only safe env vars passed to plugin processes unless "env" permission is granted. Co-Authored-By: OpenClaude (mimo-v2.5-pro) * fix(plugins): security + ESM fixes for loader and manager loader.ts: - Fix IPC: use process.send()/process.on("message") instead of worker_threads.parentPort - Fix ESM: write host script as .mjs (not .js) to force ESM execution - Add timeout: 10s default on callHook() with Promise.race - Add SIGKILL escalation: SIGTERM first, then SIGKILL after 3s grace - Fix env filtering: use allowlist (safeKeys) instead of passing all env vars - Clear timeout on successful IPC response (no timer leak) manager.ts: - Fix path traversal: use fs.realpath() instead of startsWith() - Fix imports: use registerHook/unregisterHooks from hooks.ts - Register hooks individually via registerHook(event, name, handler) hooks.ts: - Copied from feat/plugin-custom-hooks (canonical registry) * feat(discovery): add discovery tool stub service Phase 1 scaffold for automated provider discovery: - DiscoveryConfig, DiscoveryResult types - probeEndpoint() for URL availability checking - scanProvider() stub (Phase 2 will implement real scanning) - getDiscoveryResults() stub - Default config: disabled (opt-in) * chore(plugins): slop cleanup — pino logger, remove redundant sorts - index.ts: replace console.log/error with pino structured logging - hooks.ts: remove redundant .sort() in emitHookBlocking/runOnResponse (already sorted on registration) - manager.ts: add readFile import * test(plugins): add scanner, loader, manager unit tests - scanner: 9 tests (discovery, hidden dirs, validation, entry point, multiple) - loader: 5 tests (type contracts, Plugin/PluginContext/PluginResult interfaces) - manager: 6 tests (singleton, lifecycle methods, error on unknown) - Total: 20 tests, all passing * fix(settings): add missing home page pin keys to updateSettingsSchema * feat(plugins): add i18n keys to all 42 locales * fix(settings): add missing security keys to updateSettingsSchema and add tests * fix(usage): analytics route reads combo_name/requested_model from call_logs only The 3.8.6 variant of #2904 added SELECTs of combo_name/requested_model against usage_history, but those columns only exist in call_logs (no migration adds them to usage_history). This returned HTTP 500 on /api/usage/analytics. Restore the working query shape from the 3.8.7 variant. Fixes 18 failing usage-analytics-route tests. * fix(types,test): resolve noImplicitAny in progressiveAging + align semaphore test to #2903 gate pruning - progressiveAging: type compression results so messages[0].content is indexable (was TS7053 against {}); restores typecheck:noimplicit:core gate. - services-branch-hardening: #2903 (perf-ram) prunes idle rate-limit gates on zero; assert no-running/empty-queue without assuming the entry persists. * fix(analytics): address merged review regressions * fix(executor): normalize max effort for openai shape providers * Make zero-latency combo optimizations opt-in * Address zero-latency combo review feedback * chore(release): sync v3.8.7 touchpoints + credit contributors - llm.txt → 3.8.7 (Current version + Key Features header) - CHANGELOG: add Dmitry Kuznetsov & Nikolay Alafuzov to 3.8.6 Hall of Contributors - version already 3.8.7 across package.json/open-sse/electron/openapi (from #2909) * fix(cleanup): restore usage history cutoff boundary * docs(changelog): rank 3.8.6 contributors in a commits table with their PRs * fix(dashboard): theme ReactFlow Controls +/- buttons for dark mode * fix(settings): add missing home page pin keys to updateSettingsSchema * fix(settings): add missing security keys to updateSettingsSchema and add tests * fix(executor): normalize max effort for openai shape providers * Make zero-latency combo optimizations opt-in * Address zero-latency combo review feedback * fix(analytics): address merged review regressions * fix(cleanup): restore usage history cutoff boundary * feat(plugins): WordPress-style plugin system backend * fix(plugins): address code review feedback - Path traversal guard: validate entryPoint stays within plugin dir - install() now handles direct plugin directories (not just parent dirs) - Non-null assertion replaced with explicit null check - require efficiency: allowedModules map moved outside function - Source wrapper: add newlines to prevent trailing comment issues - Config validation: validate values against configSchema on save - Dynamic import comment: clarify Node.js caching behavior Co-Authored-By: OpenClaude (mimo-v2.5-pro) * fix(plugins): replace vm with child_process, add auth to all routes Addresses all remaining code review feedback: 1. **Loader rewrite**: Replaced Node.js vm module with child_process.fork() for proper process-level isolation. Complies with Rule 3 (no eval). Each plugin runs in a separate Node.js process with IPC communication. 2. **Auth on all routes**: Added requireManagementAuth to all 6 plugin API route files (list, install, scan, details, activate, deactivate, config). 3. **Env filtering**: Only safe env vars passed to plugin processes unless "env" permission is granted. Co-Authored-By: OpenClaude (mimo-v2.5-pro) * fix(plugins): security + ESM fixes for loader and manager loader.ts: - Fix IPC: use process.send()/process.on("message") instead of worker_threads.parentPort - Fix ESM: write host script as .mjs (not .js) to force ESM execution - Add timeout: 10s default on callHook() with Promise.race - Add SIGKILL escalation: SIGTERM first, then SIGKILL after 3s grace - Fix env filtering: use allowlist (safeKeys) instead of passing all env vars - Clear timeout on successful IPC response (no timer leak) manager.ts: - Fix path traversal: use fs.realpath() instead of startsWith() - Fix imports: use registerHook/unregisterHooks from hooks.ts - Register hooks individually via registerHook(event, name, handler) hooks.ts: - Copied from feat/plugin-custom-hooks (canonical registry) * feat(discovery): add discovery tool stub service Phase 1 scaffold for automated provider discovery: - DiscoveryConfig, DiscoveryResult types - probeEndpoint() for URL availability checking - scanProvider() stub (Phase 2 will implement real scanning) - getDiscoveryResults() stub - Default config: disabled (opt-in) * chore(plugins): slop cleanup — pino logger, remove redundant sorts - index.ts: replace console.log/error with pino structured logging - hooks.ts: remove redundant .sort() in emitHookBlocking/runOnResponse (already sorted on registration) - manager.ts: add readFile import * test(plugins): add scanner, loader, manager unit tests - scanner: 9 tests (discovery, hidden dirs, validation, entry point, multiple) - loader: 5 tests (type contracts, Plugin/PluginContext/PluginResult interfaces) - manager: 6 tests (singleton, lifecycle methods, error on unknown) - Total: 20 tests, all passing * feat(plugins): add i18n keys to all 42 locales * chore(plugins): remove duplicate migration 059_create_plugins.sql * chore(plugins): remove duplicate migration 059_create_plugins.sql (post-merge) * fix(sse): guard non-string error.code in proxyFetch + harden model parsing (#2463) (#2923) Integrated into release/v3.8.7 * fix(docker): add runner-web stage with Playwright Chromium (#2832) (#2846) Integrated into release/v3.8.7 * docs(changelog): document NVIDIA NIM and error code type-crash fix (#2463) * test: ignore NVIDIA_BASE_URL and NVIDIA_MODEL in env contract check --------- Co-authored-by: oyi77 Co-authored-by: OpenClaude (mimo-v2.5-pro) Co-authored-by: Apostol Apostolov Co-authored-by: Halil Tezcan KARABULUT Co-authored-by: R.D. --- CHANGELOG.md | 37 ++- Dockerfile | 40 ++- docker-compose.prod.yml | 15 + docs/guides/USER_GUIDE.md | 6 + llm.txt | 4 +- open-sse/executors/base.ts | 30 +- open-sse/handlers/chatCore.ts | 73 ++++- open-sse/mcp-server/server.ts | 27 +- open-sse/mcp-server/tools/pluginTools.ts | 149 +++++++++ open-sse/services/combo.ts | 237 ++++++++------ open-sse/services/comboConfig.ts | 3 + .../services/compression/progressiveAging.ts | 8 +- open-sse/services/model.ts | 6 +- open-sse/utils/proxyFetch.ts | 2 +- package-lock.json | 4 +- package.json | 1 + scripts/ad-hoc/nvidia-startswith-diag.ts | 183 +++++++++++ scripts/check/check-env-doc-sync.mjs | 3 + .../settings/components/ComboDefaultsTab.tsx | 24 ++ src/app/api/plugins/[name]/activate/route.ts | 30 ++ src/app/api/plugins/[name]/config/route.ts | 100 ++++++ .../api/plugins/[name]/deactivate/route.ts | 30 ++ src/app/api/plugins/[name]/route.ts | 76 +++++ src/app/api/plugins/route.ts | 74 +++++ src/app/api/plugins/scan/route.ts | 25 ++ src/app/api/settings/combo-defaults/route.ts | 1 + src/app/api/usage/analytics/route.ts | 53 ++-- src/i18n/messages/ar.json | 36 +++ src/i18n/messages/az.json | 36 +++ src/i18n/messages/bg.json | 36 +++ src/i18n/messages/bn.json | 36 +++ src/i18n/messages/cs.json | 36 +++ src/i18n/messages/da.json | 36 +++ src/i18n/messages/de.json | 36 +++ src/i18n/messages/en.json | 36 +++ src/i18n/messages/es.json | 36 +++ src/i18n/messages/fa.json | 36 +++ src/i18n/messages/fi.json | 36 +++ src/i18n/messages/fr.json | 36 +++ src/i18n/messages/gu.json | 36 +++ src/i18n/messages/he.json | 36 +++ src/i18n/messages/hi.json | 36 +++ src/i18n/messages/hu.json | 36 +++ src/i18n/messages/id.json | 36 +++ src/i18n/messages/in.json | 36 +++ src/i18n/messages/it.json | 36 +++ src/i18n/messages/ja.json | 36 +++ src/i18n/messages/ko.json | 36 +++ src/i18n/messages/mr.json | 36 +++ src/i18n/messages/ms.json | 36 +++ src/i18n/messages/nl.json | 36 +++ src/i18n/messages/no.json | 36 +++ src/i18n/messages/phi.json | 36 +++ src/i18n/messages/pl.json | 36 +++ src/i18n/messages/pt-BR.json | 47 ++- src/i18n/messages/pt.json | 36 +++ src/i18n/messages/ro.json | 36 +++ src/i18n/messages/ru.json | 36 +++ src/i18n/messages/sk.json | 36 +++ src/i18n/messages/sv.json | 36 +++ src/i18n/messages/sw.json | 36 +++ src/i18n/messages/ta.json | 36 +++ src/i18n/messages/te.json | 36 +++ src/i18n/messages/th.json | 36 +++ src/i18n/messages/tr.json | 36 +++ src/i18n/messages/uk-UA.json | 36 +++ src/i18n/messages/ur.json | 36 +++ src/i18n/messages/vi.json | 36 +++ src/i18n/messages/zh-CN.json | 36 +++ src/lib/db/cleanup.ts | 25 +- src/lib/db/migrations/076_create_plugins.sql | 31 ++ src/lib/db/plugins.ts | 195 ++++++++++++ src/lib/discovery/index.ts | 50 ++- src/lib/localDb.ts | 13 + src/lib/plugins/hooks.ts | 265 ++++++++++++++++ src/lib/plugins/index.ts | 31 +- src/lib/plugins/loader.ts | 241 ++++++++++++++ src/lib/plugins/manager.ts | 300 ++++++++++++++++++ src/lib/plugins/manifest.ts | 129 ++++++++ src/lib/plugins/scanner.ts | 110 +++++++ src/lib/usage/aggregateHistory.ts | 4 +- src/shared/validation/schemas.ts | 30 +- src/shared/validation/settingsSchemas.ts | 12 +- .../base-executor-sanitize-effort.test.ts | 38 +++ tests/unit/combo-config.test.ts | 63 ++++ tests/unit/combo-routing-engine.test.ts | 173 +++++++++- .../database-settings-maintenance.test.ts | 33 ++ tests/unit/gemini-web.test.ts | 73 +++++ .../home-page-pin-settings-schema.test.ts | 67 ++++ tests/unit/parse-model-defensive.test.ts | 32 ++ tests/unit/plugins-loader.test.ts | 78 +++++ tests/unit/plugins-manager.test.ts | 51 +++ tests/unit/plugins-scanner.test.ts | 107 +++++++ tests/unit/proxyfetch-undici-retry.test.ts | 41 +++ tests/unit/services-branch-hardening.test.ts | 6 +- tests/unit/usage-analytics-route.test.ts | 69 ++++ 96 files changed, 4846 insertions(+), 185 deletions(-) create mode 100644 open-sse/mcp-server/tools/pluginTools.ts create mode 100644 scripts/ad-hoc/nvidia-startswith-diag.ts create mode 100644 src/app/api/plugins/[name]/activate/route.ts create mode 100644 src/app/api/plugins/[name]/config/route.ts create mode 100644 src/app/api/plugins/[name]/deactivate/route.ts create mode 100644 src/app/api/plugins/[name]/route.ts create mode 100644 src/app/api/plugins/route.ts create mode 100644 src/app/api/plugins/scan/route.ts create mode 100644 src/lib/db/migrations/076_create_plugins.sql create mode 100644 src/lib/db/plugins.ts create mode 100644 src/lib/plugins/hooks.ts create mode 100644 src/lib/plugins/loader.ts create mode 100644 src/lib/plugins/manager.ts create mode 100644 src/lib/plugins/manifest.ts create mode 100644 src/lib/plugins/scanner.ts create mode 100644 tests/unit/home-page-pin-settings-schema.test.ts create mode 100644 tests/unit/parse-model-defensive.test.ts create mode 100644 tests/unit/plugins-loader.test.ts create mode 100644 tests/unit/plugins-manager.test.ts create mode 100644 tests/unit/plugins-scanner.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77af38b7db..a114d775a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### 🔧 Bug Fixes +- **sse:** guard against numeric or non-string upstream error codes and malformed model strings to prevent runtime string-method crashes in `proxyFetch`, `parseModel`, and combo routing (#2463) +- **docker:** add dedicated `runner-web` Docker stage with Playwright + Chromium + system libs so web-cookie providers (Gemini Web, Claude Turnstile) work in container deployments without bloating the base image (#2832) - **token-accounting:** prefer `prompt_tokens` over compatibility `input_tokens` for Anthropic Claude streams to avoid double-counting cached tokens (#2904 — thanks @unitythemaker). - **agy:** add the **Antigravity CLI (`agy`)** as a standalone OAuth provider next to `gemini-cli`/`antigravity`. It reuses the antigravity inference backend (identical Google client, `daily-cloudcode-pa.googleapis.com`) but ships its own model catalog — notably the Claude models the backend exposes (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`) — its own account pool, and connection methods: import the `agy` CLI token file (paste/upload), auto-detect a local CLI login (`~/.gemini/antigravity-cli/antigravity-oauth-token`), browser OAuth, and bulk/ZIP import. New routes: `POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}`. @@ -131,10 +133,39 @@ - **gitignore:** ignore `.claude/settings.local.json` so per-user Claude Code permissions never get committed by accident - **release:** version bump and metadata sync (package.json, package-lock.json, electron, open-sse, openapi.yaml) -### 🏆 Hall of Contributors +### 🏆 Contributors -A special thanks to everyone who contributed code, reviews, and tests for this release: -@akarray, @alltomatos, @androw, @apoapostolov, @Ardem2025, @dhaern, @disonjer, @gogones, @hartmark, @herjarsa, @InkshadeWoods, @jeferssonlemes, @leninejunior, @levonk, @marchlhw, @mugnimaestra, @nickwizard, @oyi77, @RajvardhanPatil07, @rdself, @soyelmismo, @Tushar49, @yunaamelia +A special thanks to everyone who contributed to this release. Ranked by commits since `v3.8.6` (105 commits total): + +| Contributor | Commits | PRs | +| --- | ---: | --- | +| [@diegosouzapw](https://github.com/diegosouzapw) | 38 | maintainer — releases, upstream ports & fixes | +| [@oyi77](https://github.com/oyi77) | 10 | #2887, #2862, #2866, #2837, #2885, #2792, #2793 | +| [@yunaamelia](https://github.com/yunaamelia) | 7 | #2884 | +| [@herjarsa](https://github.com/herjarsa) | 6 | #2868, #2886, #2865, #2860, #2857, #2801 | +| [@leninejunior](https://github.com/leninejunior) | 4 | #2818, #2824, #2825, #2816 | +| [@jeferssonlemes](https://github.com/jeferssonlemes) | 3 | #2791, #2802, #2815, #2817 | +| [@rdself](https://github.com/rdself) | 3 | #2874, #2875, #2880 | +| Dmitry Kuznetsov | 3 | textual tool-call & lockout hardening | +| [@apoapostolov](https://github.com/apoapostolov) | 2 | #2799, #2800 | +| [@unitythemaker](https://github.com/unitythemaker) | 2 | #2904 | +| Nikolay Alafuzov | 2 | reasoning interleaved gating | +| [@Tushar49](https://github.com/Tushar49) | 2 | #2854, #2855, #2807 | +| [@guanbear](https://github.com/guanbear) | 2 | #2908 | +| [@soyelmismo](https://github.com/soyelmismo) | 2 | #2903, #2842 | +| [@RajvardhanPatil07](https://github.com/RajvardhanPatil07) | 1 | #2861 | +| [@mugnimaestra](https://github.com/mugnimaestra) | 1 | #2888 | +| [@dhaern](https://github.com/dhaern) | 1 | #2878 | +| [@hartmark](https://github.com/hartmark) | 1 | #2795, #2771 | +| [@marchlhw](https://github.com/marchlhw) | 1 | #2821 | +| [@alltomatos](https://github.com/alltomatos) | 1 | i18n pt-BR | +| [@akarray](https://github.com/akarray) | 1 | #2796 | +| [@gogones](https://github.com/gogones) | 1 | #2845 | +| [@disonjer](https://github.com/disonjer) | 1 | #2840 | +| [@nickwizard](https://github.com/nickwizard) | 1 | #2841 | +| [@levonk](https://github.com/levonk) | 1 | #2806 | + +_Reviews & additional contributions: @androw, @Ardem2025, @InkshadeWoods._ --- diff --git a/Dockerfile b/Dockerfile index 8955bced06..3e3331a5df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,17 +89,53 @@ RUN chown -R node:node /app EXPOSE 20128 +# Drop to non-root before ENTRYPOINT/CMD so every derived stage (runner-cli, +# runner-web) also runs as a non-root user unless they explicitly switch back. +USER node + # Warns if the mounted data volume has wrong ownership COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh ENTRYPOINT ["/tmp/check-permissions.sh"] -USER node - HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD ["node", "healthcheck.mjs"] CMD ["node", "dev/run-standalone.mjs"] +# ── Runner Web (web-cookie providers: Gemini Web, Claude Turnstile) ─────────── +# +# Two image flavors: +# runner-base → omniroute:VERSION Lean base (~500 MB). No browsers. +# runner-web → omniroute:VERSION-web +Chromium/Playwright (~800 MB). +# +# Use runner-web when you need web-cookie providers (gemini-web, claude-web, +# claude-turnstile). For all other providers runner-base is sufficient. +# +# Build: +# docker build --target runner-web -t omniroute:web . +# Compose: +# build: +# context: . +# target: runner-web +FROM runner-base AS runner-web + +USER root + +# Install Playwright browser binaries + OS dependencies under root, then hand +# ownership of the browsers cache to the node user. +# PLAYWRIGHT_BROWSERS_PATH overrides the default ~/.cache/ms-playwright so the +# browsers land under /home/node which persists across image layers and is +# accessible to the non-root runtime user. +ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ + apt-get update \ + && npx playwright install chromium --with-deps \ + && chown -R node:node /home/node/.cache \ + && rm -rf /var/lib/apt/lists/* + +USER node + FROM runner-base AS runner-cli # Drop back to root briefly so we can install system + global npm packages, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 420c8bb7c7..b442de5f10 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,6 +9,21 @@ # docker compose -f docker-compose.prod.yml up -d --build # docker compose -f docker-compose.prod.yml down # docker compose -f docker-compose.prod.yml logs -f +# +# Image flavors (two Dockerfile stages): +# runner-base (default / omniroute:prod) +# Lean image — no Playwright/Chromium. Suitable for all providers +# except web-cookie ones (gemini-web, claude-web, claude-turnstile). +# +# runner-web (omniroute:prod-web) — opt-in, ~300 MB extra +# Includes Playwright + Chromium system libs. Required for web-cookie +# providers. To use this flavor, override the build target: +# +# omniroute-prod: +# build: +# context: . +# target: runner-web +# image: omniroute:prod-web # ────────────────────────────────────────────────────────────────────── services: diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index dc0a51f747..bfb3c08368 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -981,6 +981,12 @@ Combo target timeouts inherit the current request timeout by default. Use **Targ (seconds)** on combo defaults or an individual combo only when a shorter per-target limit should trigger faster fallback. +Zero-latency combo optimizations are opt-in. Leave **Zero-latency optimizations** disabled to +prevent these latency features from racing fallback targets, skipping targets based on TTFT +history, or compressing fallback requests; enabling it allows configured hedging, predictive TTFT +skips, and proactive fallback compression to trade routing/request fidelity for lower tail +latency. + --- ### Health Dashboard diff --git a/llm.txt b/llm.txt index 99c9c94b41..4d6320ec75 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.5 +**Current version:** 3.8.7 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.5) +## Key Features (v3.8.7) ### Core Proxy - **177 AI providers** with automatic format translation diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index f45c12fe68..084db6a919 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -221,7 +221,8 @@ function hasActiveClaudeThinking(body: Record): boolean { * through unchanged, and Claude models default to xhigh support unless marked * as legacy unsupported entries. max support is Claude/CC-compatible only and * intentionally separate: older Opus/Sonnet models may support max even when - * they do not support xhigh. + * they do not support xhigh. For OpenAI-shape providers, normalize max to + * xhigh when that top tier is allowed; otherwise downgrade to high. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; @@ -251,9 +252,27 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; const modelStr = model || ""; - const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHighEffort(provider, modelStr); + const supportsXHigh = supportsXHighEffort(provider, modelStr); + const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; + const shouldNormalizeMaxToXHigh = + effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && supportsXHigh; const shouldDowngradeMax = - effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr); + effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && !supportsXHigh; + + if (shouldNormalizeMaxToXHigh) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: normalized reasoning_effort max → xhigh` + ); + const next: Record = { ...b }; + if (hasTopLevelReasoningEffort) { + next.reasoning_effort = "xhigh"; + } + if (reasoning) { + next.reasoning = { ...reasoning, effort: "xhigh" }; + } + return next; + } if (shouldDowngradeXHigh || shouldDowngradeMax) { log?.info?.( @@ -797,7 +816,10 @@ export class BaseExecutor { delete (tb.output_config as Record).effort; } appliedEffort = "off"; - } else if (headerEffort && ["low", "medium", "high", "xhigh", "max"].includes(headerEffort)) { + } else if ( + headerEffort && + ["low", "medium", "high", "xhigh", "max"].includes(headerEffort) + ) { const oc = tb.output_config && typeof tb.output_config === "object" ? (tb.output_config as Record) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 49ebfcdc6c..5c9c571148 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1525,6 +1525,51 @@ export async function handleChatCore({ }; let tokensCompressed: number | null = null; body = injectSystemPrompt(body); + // ── Plugin onRequest hook ── + // Dynamic import cached by Node.js after first call — minimal overhead + try { + const { runOnRequest } = await import("@/lib/plugins/index"); + const pluginCtx = { + requestId: traceId, + body, + model, + provider, + apiKeyInfo, + metadata: {}, + }; + const pluginResult = await runOnRequest(pluginCtx); + if (pluginResult?.blocked) { + log?.info?.("PLUGIN", `Request blocked by plugin`); + return { + success: false, + status: 403, + error: "Request blocked by plugin", + response: pluginResult.response + ? new Response(JSON.stringify(pluginResult.response), { + status: 403, + headers: { "Content-Type": "application/json" }, + }) + : new Response( + JSON.stringify({ + error: { message: "Request blocked by plugin", type: "plugin_block" }, + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + } + ), + }; + } + if (pluginResult?.ctx && "body" in pluginResult.ctx) { + body = (pluginResult.ctx as unknown as Record).body; + } + } catch (pluginErr) { + log?.debug?.( + "PLUGIN", + `onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` + ); + } + type EffectiveServiceTier = "standard" | CodexServiceTier; let effectiveServiceTier: EffectiveServiceTier = "standard"; const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => { @@ -3199,6 +3244,20 @@ export async function handleChatCore({ ); } } catch (error) { + // ── Plugin onError hook ── + try { + const { runOnError } = await import("@/lib/plugins/index"); + await runOnError( + { requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} }, + error instanceof Error ? error : new Error(String(error)) + ); + } catch (pluginErr) { + log?.debug?.( + "PLUGIN", + `onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` + ); + } + const parsedStatus = Number(error?.statusCode); const statusCode = Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599 @@ -3254,10 +3313,16 @@ export async function handleChatCore({ // Update model in body — use resolved alias so the provider gets the correct model ID (#472) // Strip provider/alias prefix if it exactly matches the routing prefix so upstream receives the raw model name (#1261) let finalModelToUpstream = effectiveModel; - if (finalModelToUpstream.startsWith(`${provider}/`)) { - finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); - } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { - finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + // Defense-in-depth: only string-strip when effectiveModel is actually a string. + // The API guards `model` via Zod (z.string()), but internal callers could pass a + // non-string and a bare `.startsWith` would crash with `startsWith is not a + // function` (same class as #2359 / #2463). Mirrors 9router's `?.startsWith?.()`. + if (typeof finalModelToUpstream === "string") { + if (finalModelToUpstream.startsWith(`${provider}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); + } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + } } translatedBody.model = finalModelToUpstream; diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c24757d3d6..7b75448745 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -75,6 +75,7 @@ import { } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; +import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; @@ -102,7 +103,8 @@ const TOTAL_MCP_TOOL_COUNT = MCP_TOOLS.length + Object.keys(memoryTools).length + Object.keys(skillTools).length + - gamificationTools.length; + gamificationTools.length + + pluginTools.length; type JsonRecord = Record; @@ -1003,6 +1005,29 @@ export function createMcpServer(): McpServer { ); }); + // ── Plugin Tools ────────────────────────────── + pluginTools.forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + // ── Compression Tools ───────────────────────── Object.values(compressionTools).forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts new file mode 100644 index 0000000000..a6d4a3f9b1 --- /dev/null +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -0,0 +1,149 @@ +/** + * MCP Plugin Tools — 8 tools for plugin management. + * + * @module mcp-server/tools/pluginTools + */ + +import { z } from "zod"; +import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins"; +import { pluginManager } from "../../../src/lib/plugins/manager"; + +export const pluginTools = [ + { + name: "plugin_list", + description: "List all installed plugins with their status, hooks, and metadata.", + inputSchema: z.object({ + status: z + .enum(["installed", "active", "inactive", "error"]) + .optional() + .describe("Filter by plugin status"), + }), + handler: async (args: { status?: string }) => { + const plugins = listPlugins(args.status as any); + return { + plugins: plugins.map((p) => ({ + name: p.name, + version: p.version, + description: p.description, + status: p.status, + enabled: p.enabled === 1, + hooks: JSON.parse(p.hooks || "[]"), + permissions: JSON.parse(p.permissions || "[]"), + installedAt: p.installedAt, + activatedAt: p.activatedAt, + })), + }; + }, + }, + + { + name: "plugin_install", + description: "Install a plugin from a local directory path.", + inputSchema: z.object({ + path: z.string().describe("Absolute path to the plugin directory containing plugin.json"), + }), + handler: async (args: { path: string }) => { + const plugin = await pluginManager.install(args.path); + return { + success: true, + plugin: { + name: plugin.name, + version: plugin.version, + status: plugin.status, + }, + }; + }, + }, + + { + name: "plugin_activate", + description: "Activate an installed plugin (loads hooks into the request pipeline).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.activate(args.name); + return { success: true, message: `Plugin '${args.name}' activated` }; + }, + }, + + { + name: "plugin_deactivate", + description: "Deactivate an active plugin (unloads hooks from the request pipeline).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.deactivate(args.name); + return { success: true, message: `Plugin '${args.name}' deactivated` }; + }, + }, + + { + name: "plugin_uninstall", + description: "Uninstall a plugin (deactivates, removes files, removes from DB).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.uninstall(args.name); + return { success: true, message: `Plugin '${args.name}' uninstalled` }; + }, + }, + + { + name: "plugin_configure", + description: "Get or update a plugin's configuration.", + inputSchema: z.object({ + name: z.string().describe("Plugin name"), + config: z + .record(z.string(), z.unknown()) + .optional() + .describe("New config values to merge (omit to just read current config)"), + }), + handler: async (args: { name: string; config?: Record }) => { + const plugin = getPluginByName(args.name); + if (!plugin) throw new Error(`Plugin '${args.name}' not found`); + + if (args.config) { + const current = JSON.parse(plugin.config || "{}"); + const merged = { ...current, ...args.config }; + updatePluginConfig(args.name, merged); + return { success: true, config: merged }; + } + + return { + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + }; + }, + }, + + { + name: "plugin_executions", + description: "View plugin execution history (from skill_executions table).", + inputSchema: z.object({ + name: z.string().optional().describe("Filter by plugin name"), + limit: z.number().min(1).max(100).default(20).describe("Max results to return"), + }), + handler: async (args: { name?: string; limit?: number }) => { + // Plugin executions are tracked via the skills system + const { skillExecutor } = await import("../../../src/lib/skills/executor"); + const executions = skillExecutor.listExecutions(undefined, args.limit || 20); + return { executions }; + }, + }, + + { + name: "plugin_scan", + description: "Scan the plugin directory for new plugins and sync with DB.", + inputSchema: z.object({}), + handler: async () => { + const result = await pluginManager.scan(); + return { + discovered: result.discovered, + errors: result.errors, + }; + }, + }, +]; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 3a395c7e92..84e6849b9e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -2508,73 +2508,73 @@ export async function handleComboChat({ const transform = new TransformStream( { - transform(chunk, controller) { - if (tagInjected) { - // Already injected — passthrough + transform(chunk, controller) { + if (tagInjected) { + // Already injected — passthrough + controller.enqueue(chunk); + return; + } + + const text = decoder.decode(chunk, { stream: true }); + + // Fix #721: Look for either non-empty content OR tool_calls in the + // SSE data. Tool-call-only responses have content:null, so we inject + // the tag when we see a finish_reason approaching, or on first content. + const contentMatch = RegExp(/"content":"([^"]+)/).exec(text); + if (contentMatch) { + // Inject tag at the beginning of the first content value + const injected = text.replace( + /"content":"([^"]+)/, + `"content":"${tagContent.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}$1` + ); + tagInjected = true; + controller.enqueue(encoder.encode(injected)); + return; + } + + // Fix #721: For tool-call-only streams, inject the tag when we see + // the finish_reason chunk (before it reaches the client SDK which + // would close the connection). This ensures the tag roundtrips + // through the conversation history even when there's no text content. + if (text.includes('"finish_reason"') && !text.includes('"finish_reason":null')) { + // Inject a content chunk with the tag just before this finish chunk + const tagChunk = `data: ${JSON.stringify({ + choices: [ + { + delta: { content: tagContent }, + index: 0, + finish_reason: null, + }, + ], + })}\n\n`; + tagInjected = true; + controller.enqueue(encoder.encode(tagChunk)); + controller.enqueue(chunk); + return; + } + + // No content yet — passthrough controller.enqueue(chunk); - return; - } - - const text = decoder.decode(chunk, { stream: true }); - - // Fix #721: Look for either non-empty content OR tool_calls in the - // SSE data. Tool-call-only responses have content:null, so we inject - // the tag when we see a finish_reason approaching, or on first content. - const contentMatch = RegExp(/"content":"([^"]+)/).exec(text); - if (contentMatch) { - // Inject tag at the beginning of the first content value - const injected = text.replace( - /"content":"([^"]+)/, - `"content":"${tagContent.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}$1` - ); - tagInjected = true; - controller.enqueue(encoder.encode(injected)); - return; - } - - // Fix #721: For tool-call-only streams, inject the tag when we see - // the finish_reason chunk (before it reaches the client SDK which - // would close the connection). This ensures the tag roundtrips - // through the conversation history even when there's no text content. - if (text.includes('"finish_reason"') && !text.includes('"finish_reason":null')) { - // Inject a content chunk with the tag just before this finish chunk - const tagChunk = `data: ${JSON.stringify({ - choices: [ - { - delta: { content: tagContent }, - index: 0, - finish_reason: null, - }, - ], - })}\n\n`; - tagInjected = true; - controller.enqueue(encoder.encode(tagChunk)); - controller.enqueue(chunk); - return; - } - - // No content yet — passthrough - controller.enqueue(chunk); + }, + flush(controller) { + // If stream ends without ever finding content (edge case), + // inject tag as a standalone chunk before the stream closes + if (!tagInjected) { + const tagChunk = `data: ${JSON.stringify({ + choices: [ + { + delta: { content: tagContent }, + index: 0, + finish_reason: null, + }, + ], + })}\n\n`; + controller.enqueue(encoder.encode(tagChunk)); + } + }, }, - flush(controller) { - // If stream ends without ever finding content (edge case), - // inject tag as a standalone chunk before the stream closes - if (!tagInjected) { - const tagChunk = `data: ${JSON.stringify({ - choices: [ - { - delta: { content: tagContent }, - index: 0, - finish_reason: null, - }, - ], - })}\n\n`; - controller.enqueue(encoder.encode(tagChunk)); - } - }, - }, - { highWaterMark: 16384 }, - { highWaterMark: 16384 } + { highWaterMark: 16384 }, + { highWaterMark: 16384 } ); const transformedStream = res.body.pipeThrough(transform); @@ -3157,12 +3157,17 @@ export async function handleComboChat({ let recordedAttempts = 0; let globalResolve: ((res: Response) => void) | null = null; - const globalPromise = new Promise((res) => { globalResolve = res; }); + const globalPromise = new Promise((res) => { + globalResolve = res; + }); const runningTasks = new Set>(); let anySuccess = false; const abortControllers = new Map(); + const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true; - const executeTarget = async (i: number): Promise<{ ok: boolean; response?: Response } | null> => { + const executeTarget = async ( + i: number + ): Promise<{ ok: boolean; response?: Response } | null> => { const target = orderedTargets[i]; const modelStr = target.modelStr; const provider = target.provider; @@ -3170,7 +3175,11 @@ export async function handleComboChat({ const allowRateLimitedConnection = Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider); const targetForAttempt = allowRateLimitedConnection - ? { ...target, allowRateLimitedConnection: true, modelAbortSignal: abortControllers.get(i)!.signal } + ? { + ...target, + allowRateLimitedConnection: true, + modelAbortSignal: abortControllers.get(i)!.signal, + } : { ...target, modelAbortSignal: abortControllers.get(i)!.signal }; // #1731: Skip targets from a provider that already signaled full quota exhaustion this request. @@ -3189,7 +3198,7 @@ export async function handleComboChat({ if (!available) { log.info("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`); if (i > 0) fallbackCount++; - return null; + return null; } } @@ -3200,7 +3209,7 @@ export async function handleComboChat({ if (gateResult.allowed === false) { logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked"); if (i > 0) fallbackCount++; - return null; + return null; } } @@ -3221,15 +3230,23 @@ export async function handleComboChat({ } // Predictive TTFT Circuit Breaker (skip slow models) - if (config.predictiveTtftMs && config.predictiveTtftMs > 0 && retry === 0) { + if ( + zeroLatencyOptimizationsEnabled && + config.predictiveTtftMs && + config.predictiveTtftMs > 0 && + retry === 0 + ) { const cMetrics = getComboMetrics(combo.name); if (cMetrics) { - const targetKey = orderedTargets[i].executionKey || modelStr; - const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr]; - if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) { - log.warn("COMBO", `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)`); - return null; - } + const targetKey = orderedTargets[i].executionKey || modelStr; + const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr]; + if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) { + log.warn( + "COMBO", + `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` + ); + return null; + } } } @@ -3273,14 +3290,26 @@ export async function handleComboChat({ let attemptBody = JSON.parse(JSON.stringify(body)); // Proactive Context Compression for fallbacks (Zero-Latency optimization) - if (i > 0 && config.fallbackCompressionMode && config.fallbackCompressionMode !== "off") { + if ( + zeroLatencyOptimizationsEnabled && + i > 0 && + config.fallbackCompressionMode && + config.fallbackCompressionMode !== "off" + ) { const { estimateTokens } = await import("./contextManager.ts"); const estimatedTokens = estimateTokens(JSON.stringify(attemptBody)); if (estimatedTokens > (config.fallbackCompressionThreshold ?? 1000)) { const { applyCompression } = await import("./compression/strategySelector.ts"); - const compressionResult = applyCompression(attemptBody, config.fallbackCompressionMode as CompressionMode, { model: modelStr }); + const compressionResult = applyCompression( + attemptBody, + config.fallbackCompressionMode as CompressionMode, + { model: modelStr } + ); if (compressionResult.compressed) { - log.info("COMBO", `Proactive fallback compression applied (${config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens`); + log.info( + "COMBO", + `Proactive fallback compression applied (${config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens` + ); attemptBody = compressionResult.body; } } @@ -3522,8 +3551,7 @@ export async function handleComboChat({ isStreamReadinessFailureErrorBody(errorBody); // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. - const isTokenLimitBreach = - result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); // Fix #1681: Status 499 means client disconnected — stop combo loop immediately. // There is no point trying fallback models when nobody is listening. @@ -3551,8 +3579,19 @@ export async function handleComboChat({ const structuredError = rawError && typeof rawError === "object" ? { - code: (rawError as Record).code as string, - type: (rawError as Record).type as string, + // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). + // Coerce to string if present instead of discarding, so downstream string + // ops (.toLowerCase, .startsWith) can run safely without type crashes. + code: + (rawError as Record).code !== undefined && + (rawError as Record).code !== null + ? String((rawError as Record).code) + : undefined, + type: + (rawError as Record).type !== undefined && + (rawError as Record).type !== null + ? String((rawError as Record).type) + : undefined, } : undefined; const fallbackResult = checkFallbackError( @@ -3598,10 +3637,10 @@ export async function handleComboChat({ result.status === 400 && fallbackResult.shouldFallback && (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY || - errorText.toLowerCase().includes('context') || - errorText.toLowerCase().includes('malformed') || - errorText.toLowerCase().includes('invalid') || - errorText.toLowerCase().includes('bad request')) + errorText.toLowerCase().includes("context") || + errorText.toLowerCase().includes("malformed") || + errorText.toLowerCase().includes("invalid") || + errorText.toLowerCase().includes("bad request")) ) { log.warn( "COMBO", @@ -3695,7 +3734,7 @@ export async function handleComboChat({ for (let i = 0; i < orderedTargets.length; i++) { if (anySuccess) break; - + const abortController = new AbortController(); abortControllers.set(i, abortController); const onClientAbort = () => abortController.abort(); @@ -3728,7 +3767,7 @@ export async function handleComboChat({ runningTasks.add(task); task.finally(() => runningTasks.delete(task)); - if (config.hedging && i + 1 < orderedTargets.length) { + if (zeroLatencyOptimizationsEnabled && config.hedging && i + 1 < orderedTargets.length) { const hedgeDelay = resolveDelayMs(config.hedgeDelayMs, 500); let timeoutResolve: () => void; const timeoutPromise = new Promise((r) => { @@ -4077,8 +4116,7 @@ async function handleRoundRobinCombo({ isStreamReadinessFailureErrorBody(errorBody); // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. - const isTokenLimitBreach = - result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); // Round-robin uses the same target-level fallback rule as other combo // strategies: non-ok target responses fall through to the next target. @@ -4088,8 +4126,19 @@ async function handleRoundRobinCombo({ const structuredError = rawError && typeof rawError === "object" ? { - code: (rawError as Record).code as string, - type: (rawError as Record).type as string, + // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). + // Coerce to string if present instead of discarding, so downstream string + // ops (.toLowerCase, .startsWith) can run safely without type crashes. + code: + (rawError as Record).code !== undefined && + (rawError as Record).code !== null + ? String((rawError as Record).code) + : undefined, + type: + (rawError as Record).type !== undefined && + (rawError as Record).type !== null + ? String((rawError as Record).type) + : undefined, } : undefined; const fallbackResult = checkFallbackError( diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 1a5c386c3d..64ef751d00 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -28,6 +28,9 @@ const DEFAULT_COMBO_CONFIG = { failoverBeforeRetry: true, maxSetRetries: 0, setRetryDelayMs: 2000, + // Zero-latency optimizations are opt-in because some modes can race targets or + // mutate fallback request bodies for lower tail latency. + zeroLatencyOptimizationsEnabled: false, // Hedging (Speculative Execution) defaults hedging: false, hedgeDelayMs: 500, diff --git a/open-sse/services/compression/progressiveAging.ts b/open-sse/services/compression/progressiveAging.ts index be50599d07..3dbbdd6d77 100644 --- a/open-sse/services/compression/progressiveAging.ts +++ b/open-sse/services/compression/progressiveAging.ts @@ -12,6 +12,10 @@ function estimateTokens(text: string): number { type ChatMessage = ChatMessageLike; +type CompressedResult = { + body?: { messages?: Array<{ content?: ChatMessageLike["content"] }> }; +}; + function setContent(msg: ChatMessage, newContent: string): ChatMessage { return replaceTextContent(msg, newContent) as ChatMessage; } @@ -52,7 +56,7 @@ export function applyAging( if (distanceFromEnd <= t.verbatim) { result.push(msg); } else if (distanceFromEnd <= t.light) { - const compressed = applyLiteCompression({ messages: [msg] }); + const compressed = applyLiteCompression({ messages: [msg] }) as CompressedResult; if (compressed?.body?.messages?.[0]?.content) { const newContent = typeof compressed.body.messages[0].content === "string" @@ -65,7 +69,7 @@ export function applyAging( result.push(msg); } } else if (distanceFromEnd <= t.moderate) { - const compressed = cavemanCompress({ messages: [msg] as unknown as Parameters[0]["messages"] }); + const compressed = cavemanCompress({ messages: [msg] as unknown as Parameters[0]["messages"] }) as CompressedResult; if (compressed?.body?.messages?.[0]?.content) { const newContent = typeof compressed.body.messages[0].content === "string" diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 6b64c846a6..83eac8100c 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -287,7 +287,11 @@ export function resolveCanonicalProviderModel( * Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]") */ export function parseModel(modelStr: string | null | undefined): ParsedModel { - if (!modelStr) { + // Guard truthy non-strings (object/number/array), not just falsy values — a + // malformed combo `modelStr` or providerSpecificData saved as an object would + // otherwise reach `cleanStr.endsWith("[1m]")` and crash with + // `endsWith is not a function`. Same class as #2359 / #2463. + if (!modelStr || typeof modelStr !== "string") { return { provider: null, model: null, diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index c8b4deb482..1e402cd480 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -317,7 +317,7 @@ async function patchedFetch( msg.includes("fetch failed") || errCode === "ECONNREFUSED" || msg.includes("ECONNREFUSED") || - (errCode !== undefined && errCode.startsWith("UND_ERR")) || + (typeof errCode === "string" && errCode.startsWith("UND_ERR")) || msg.includes("UND_ERR") ) { if (attempt === 0 && maxAttempts > 1) { diff --git a/package-lock.json b/package-lock.json index 90b316fd58..75f1952a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", + "playwright": "1.60.0", "proxifly": "^3.0.1", "react": "19.2.6", "react-dom": "19.2.6", @@ -11319,7 +11320,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -16939,7 +16939,6 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -16958,7 +16957,6 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 39c427ea7e..f868e4488d 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", + "playwright": "1.60.0", "proxifly": "^3.0.1", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/scripts/ad-hoc/nvidia-startswith-diag.ts b/scripts/ad-hoc/nvidia-startswith-diag.ts new file mode 100644 index 0000000000..acf4a8b5b5 --- /dev/null +++ b/scripts/ad-hoc/nvidia-startswith-diag.ts @@ -0,0 +1,183 @@ +/** + * Diagnóstico NVIDIA NIM — `s.startsWith is not a function` (issue #2463 / report 3.8.5+) + * + * Como rodar (a chave NUNCA é commitada — vem por env): + * NVIDIA_API_KEY="nvapi-..." node --import tsx/esm scripts/ad-hoc/nvidia-startswith-diag.ts + * + * Opcional — apontar para outra base/model: + * NVIDIA_BASE_URL="https://integrate.api.nvidia.com/v1/chat/completions" + * NVIDIA_MODEL="openai/gpt-oss-120b" + * + * O que ele faz: + * Parte A — Validação real via validateProviderApiKey() (caminho do botão "testar conexão"). + * Parte B — Sanidade do upstream: POST direto na NVIDIA (isola a chave/model do nosso pipeline). + * Parte C — Probes de type-crash SEM chave: alimenta model malformado em resolveModelAlias + + * replica o strip de prefixo de chatCore.ts:3316 e parseModel(), capturando o stack + * NÃO-minificado (rodamos contra a fonte TS) para cravar a linha exata do startsWith. + * + * Parte C não precisa de chave — prova quais linhas são vulneráveis hoje. + */ + +const KEY = process.env.NVIDIA_API_KEY ?? ""; +const BASE_URL = process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions"; +const MODEL = process.env.NVIDIA_MODEL || "openai/gpt-oss-120b"; + +const line = (s = "") => console.log(s); +const hr = () => line("─".repeat(72)); + +function show(label: string, value: unknown) { + line(` ${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`); +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte A — validateProviderApiKey (caminho de validação/teste de conexão) +// ────────────────────────────────────────────────────────────────────────── +async function partA() { + hr(); + line("PARTE A — validateProviderApiKey({ provider: 'nvidia' })"); + hr(); + if (!KEY) { + line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar."); + return; + } + try { + const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + const providerSpecificData = { baseUrl: BASE_URL }; + const result = await validateProviderApiKey({ + provider: "nvidia", + apiKey: KEY, + providerSpecificData, + }); + line(" ✅ validateProviderApiKey retornou (sem crash):"); + show("resultado", result); + if (typeof (result as any)?.error === "string" && (result as any).error.includes("startsWith")) { + line(" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação."); + } + } catch (err: any) { + line(" ❌ validateProviderApiKey LANÇOU (crash não tratado):"); + line(` ${err?.message}`); + line(err?.stack ?? String(err)); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte B — sanidade do upstream NVIDIA (isola chave/model do nosso pipeline) +// ────────────────────────────────────────────────────────────────────────── +async function partB() { + hr(); + line("PARTE B — POST direto no upstream NVIDIA (sanidade da chave/model)"); + hr(); + if (!KEY) { + line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar."); + return; + } + const url = BASE_URL.endsWith("/chat/completions") ? BASE_URL : `${BASE_URL}/chat/completions`; + show("url", url); + show("model", MODEL); + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` }, + body: JSON.stringify({ + model: MODEL, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); + const text = await res.text(); + show("status", res.status); + line(` body (256): ${text.slice(0, 256)}`); + if (res.ok) line(" ✅ upstream OK — chave e model válidos."); + else if (res.status === 401 || res.status === 403) line(" ❌ chave inválida (401/403)."); + else line(" ⚠️ não-OK não-auth — chave provavelmente válida, ver corpo."); + } catch (err: any) { + line(` ❌ fetch falhou: ${err?.message}`); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte C — probes de type-crash (sem chave) — crava a linha do startsWith +// ────────────────────────────────────────────────────────────────────────── +async function partC() { + hr(); + line("PARTE C — probes de type-crash (resolveModelAlias + strip chatCore:3316 + parseModel)"); + hr(); + + const { resolveModelAlias } = await import("../../open-sse/services/modelDeprecation.ts"); + const { parseModel } = await import("../../open-sse/services/model.ts"); + + // Replica EXATA do trecho de chatCore.ts:3315-3320 (feature #1261), sem guard. + function stripPrefixLikeChatCore(effectiveModel: any, provider: string, alias?: string) { + let finalModelToUpstream = effectiveModel; + if (finalModelToUpstream.startsWith(`${provider}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); + } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + } + return finalModelToUpstream; + } + + const inputs: Array<{ label: string; model: any }> = [ + { label: "string normal (multi-barra NVIDIA)", model: "nvidia/openai/gpt-oss-120b" }, + { label: "objeto {} (UI bug / providerSpecificData mal salvo)", model: {} }, + { label: "objeto {id: '...'}", model: { id: "openai/gpt-oss-120b" } }, + { label: "number", model: 123 }, + { label: "array", model: ["openai/gpt-oss-120b"] }, + { label: "null", model: null }, + { label: "undefined", model: undefined }, + ]; + + for (const { label, model } of inputs) { + line(""); + line(` ▶ input: ${label} (typeof=${typeof model})`); + + // 1) resolveModelAlias — deixa não-string passar? (if (!modelId) return modelId) + let effective: any; + try { + effective = resolveModelAlias(model as any); + line(` resolveModelAlias → ${typeof effective} ${JSON.stringify(effective)}`); + } catch (err: any) { + line(` resolveModelAlias THROW: ${err?.message}`); + effective = model; + } + + // 2) strip de prefixo (chatCore:3316) — captura o stack EXATO + try { + const out = stripPrefixLikeChatCore(effective, "nvidia", "nvidia"); + line(` chatCore strip → ${JSON.stringify(out)} ✅ sem crash`); + } catch (err: any) { + line(` ❌ chatCore:3316 strip THROW: ${err?.message}`); + const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes(".ts")); + if (at) line(` ${at.trim()}`); + } + + // 3) parseModel (model.ts:315) — captura o stack EXATO + try { + const parsed = parseModel(model as any); + line(` parseModel → ${JSON.stringify(parsed)} ✅ sem crash`); + } catch (err: any) { + line(` ❌ model.ts parseModel THROW: ${err?.message}`); + const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes("model.ts")); + if (at) line(` ${at.trim()}`); + } + } +} + +async function main() { + line(""); + line("NVIDIA NIM — diagnóstico `startsWith is not a function`"); + show("NVIDIA_API_KEY presente", KEY ? `sim (${KEY.slice(0, 6)}…)` : "não"); + show("BASE_URL", BASE_URL); + show("MODEL", MODEL); + line(""); + await partA(); + await partB(); + await partC(); + hr(); + line("FIM."); +} + +main().catch((e) => { + console.error("erro fatal no diagnóstico:", e); + process.exit(1); +}); diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 0ecd122b00..3a9742832b 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -122,6 +122,9 @@ const IGNORE_FROM_CODE = new Set([ // Node.js module resolution path — OS/Node internal, not an OmniRoute config var. // Referenced in resolveSpawnArgs (ninerouter) to pass bundled native modules to subprocess. "NODE_PATH", + // NVIDIA diagnostic/test helpers used only by ad-hoc scripts. + "NVIDIA_BASE_URL", + "NVIDIA_MODEL", ]); // Vars documented in ENVIRONMENT.md but intentionally absent from .env.example. diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 6f73726865..7e8c1537fd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -97,6 +97,7 @@ export default function ComboDefaultsTab() { stickyRoundRobinLimit: 3, resetAwareQuotaCacheTtlMs: 0, resetAwareQuotaCacheMaxStaleMs: 0, + zeroLatencyOptimizationsEnabled: false, }); const [codexSessionAffinityTtlMs, setCodexSessionAffinityTtlMs] = useState(0); const [providerOverrides, setProviderOverrides] = useState({}); @@ -555,6 +556,29 @@ export default function ComboDefaultsTab() { } /> +
+
+

+ {translateOrFallback(t, "zeroLatencyOptimizations", "Zero-latency optimizations")} +

+

+ {translateOrFallback( + t, + "zeroLatencyOptimizationsDesc", + "Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests." + )} +

+
+ + setComboDefaults((prev) => ({ + ...prev, + zeroLatencyOptimizationsEnabled: prev.zeroLatencyOptimizationsEnabled !== true, + })) + } + /> +
{/* Provider Overrides */} diff --git a/src/app/api/plugins/[name]/activate/route.ts b/src/app/api/plugins/[name]/activate/route.ts new file mode 100644 index 0000000000..d86c32f379 --- /dev/null +++ b/src/app/api/plugins/[name]/activate/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/[name]/activate — Activate a plugin + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.activate(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' activated` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/[name]/config/route.ts b/src/app/api/plugins/[name]/config/route.ts new file mode 100644 index 0000000000..5a9100c7cc --- /dev/null +++ b/src/app/api/plugins/[name]/config/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { z } from "zod"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins/[name]/config — Get plugin configuration + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const plugin = getPluginByName(name); + + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json( + { + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + }, + { headers: CORS_HEADERS } + ); +} + +/** + * PUT /api/plugins/[name]/config — Update plugin configuration + */ +export async function PUT(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const body = await request.json(); + + const schema = z.object({ + config: z.record(z.string(), z.unknown()), + }); + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.issues }, + { status: 400, headers: CORS_HEADERS } + ); + } + + const plugin = getPluginByName(name); + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + // Validate config values against configSchema if defined + const configSchema = JSON.parse(plugin.configSchema || "{}"); + if (Object.keys(configSchema).length > 0) { + for (const [key, value] of Object.entries(parsed.data.config)) { + const field = configSchema[key]; + if (!field) continue; // Allow extra keys + if (field.type === "number" && typeof value === "number") { + if (field.min !== undefined && value < field.min) { + return NextResponse.json( + { error: `Config '${key}' must be >= ${field.min}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + if (field.max !== undefined && value > field.max) { + return NextResponse.json( + { error: `Config '${key}' must be <= ${field.max}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + } + if (field.type === "select" && field.enum && !field.enum.includes(String(value))) { + return NextResponse.json( + { error: `Config '${key}' must be one of: ${field.enum.join(", ")}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + } + } + + updatePluginConfig(name, parsed.data.config); + + return NextResponse.json( + { success: true, config: parsed.data.config }, + { headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/plugins/[name]/deactivate/route.ts b/src/app/api/plugins/[name]/deactivate/route.ts new file mode 100644 index 0000000000..5e68edf60e --- /dev/null +++ b/src/app/api/plugins/[name]/deactivate/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/[name]/deactivate — Deactivate a plugin + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.deactivate(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' deactivated` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/[name]/route.ts b/src/app/api/plugins/[name]/route.ts new file mode 100644 index 0000000000..d7dd54a16e --- /dev/null +++ b/src/app/api/plugins/[name]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getPluginByName } from "@/lib/db/plugins"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins/[name] — Get plugin details + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const plugin = getPluginByName(name); + + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json( + { + plugin: { + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + author: plugin.author, + license: plugin.license, + main: plugin.main, + source: plugin.source, + tags: JSON.parse(plugin.tags || "[]"), + status: plugin.status, + enabled: plugin.enabled === 1, + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + hooks: JSON.parse(plugin.hooks || "[]"), + permissions: JSON.parse(plugin.permissions || "[]"), + pluginDir: plugin.pluginDir, + errorMessage: plugin.errorMessage, + installedAt: plugin.installedAt, + updatedAt: plugin.updatedAt, + activatedAt: plugin.activatedAt, + }, + }, + { headers: CORS_HEADERS } + ); +} + +/** + * DELETE /api/plugins/[name] — Uninstall a plugin + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.uninstall(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' uninstalled` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts new file mode 100644 index 0000000000..12d8a954f5 --- /dev/null +++ b/src/app/api/plugins/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { listPlugins } from "@/lib/db/plugins"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { z } from "zod"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins — List all installed plugins + */ +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const url = new URL(request.url); + const status = url.searchParams.get("status") as any; + + try { + const plugins = listPlugins(status || undefined); + return NextResponse.json({ plugins: plugins.map(formatPlugin) }, { headers: CORS_HEADERS }); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS }); + } +} + +/** + * POST /api/plugins — Install a plugin from a local path + */ +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const body = await request.json(); + const schema = z.object({ + path: z.string().min(1), + }); + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.issues }, + { status: 400, headers: CORS_HEADERS } + ); + } + + try { + const plugin = await pluginManager.install(parsed.data.path); + return NextResponse.json( + { plugin: formatPlugin(plugin) }, + { status: 201, headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} + +function formatPlugin(row: any) { + return { + id: row.id, + name: row.name, + version: row.version, + description: row.description, + author: row.author, + status: row.status, + enabled: row.enabled === 1, + hooks: JSON.parse(row.hooks || "[]"), + permissions: JSON.parse(row.permissions || "[]"), + installedAt: row.installedAt, + updatedAt: row.updatedAt, + activatedAt: row.activatedAt, + }; +} diff --git a/src/app/api/plugins/scan/route.ts b/src/app/api/plugins/scan/route.ts new file mode 100644 index 0000000000..70510188b7 --- /dev/null +++ b/src/app/api/plugins/scan/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/scan — Scan plugin directory for new plugins + */ +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const result = await pluginManager.scan(); + return NextResponse.json( + { discovered: result.discovered, errors: result.errors }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 1840d5591e..79a2812fe5 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -55,6 +55,7 @@ export async function GET(request: Request) { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + zeroLatencyOptimizationsEnabled: false, }, providerOverrides, }); diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 6fd5475018..540f1f93d7 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -361,17 +361,29 @@ export async function GET(request: Request) { const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; // Build a UNION data source that merges recent raw rows with aggregated history. - // daily_usage_summary rows are included only when the query window extends before rawCutoffIso. - // The api_key filter is intentionally NOT applied to daily_usage_summary (api_key not stored there). - const needsAggregated = !sinceIso || sinceIso < rawCutoffIso; + // daily_usage_summary rows are included only when the query window extends before + // rawCutoffIso. They are gated off entirely when an api_key filter is active: + // daily_usage_summary does not store api_key/connection, so including it under a + // key filter would leak other keys' aggregated usage. With a key filter we serve + // only raw rows (older key-scoped data beyond retention is intentionally unavailable). + const rawCutoffDate = rawCutoffIso.split("T")[0]; + const needsAggregated = (!sinceIso || sinceIso < rawCutoffDate) && apiKeyIds.length === 0; + // Raw leg: when aggregated rows are also included, lower-bound the raw leg at the + // raw cutoff so the two legs never overlap (prevents double-counting). const rawConditions: string[] = []; - if (sinceIso) rawConditions.push("timestamp >= @since"); + if (needsAggregated) { + rawConditions.push("timestamp >= @rawCutoff"); + params.rawCutoff = rawCutoffDate; + } else if (sinceIso) { + rawConditions.push("timestamp >= @since"); + } if (untilIso) rawConditions.push("timestamp <= @until"); if (apiKeyWhere) rawConditions.push(apiKeyWhere); const rawWhere = rawConditions.length > 0 ? `WHERE ${rawConditions.join(" AND ")}` : ""; - // Aggregated rows only span dates within the requested window (no api_key filter). + // Aggregated rows span the requested window but strictly before the raw cutoff, + // so they never overlap the raw leg above (no api_key filter — see note above). const aggConditions: string[] = []; if (sinceIso) { // Use date comparison on the summary's date column (YYYY-MM-DD). @@ -384,6 +396,8 @@ export async function GET(request: Request) { aggConditions.push("date <= @untilDate"); params.untilDate = untilDate; } + aggConditions.push("date < @rawCutoffDate"); + params.rawCutoffDate = rawCutoffDate; const aggWhere = aggConditions.length > 0 ? `WHERE ${aggConditions.join(" AND ")}` : ""; // Unified source CTE: columns aligned to usage_history shape needed by analytics queries. @@ -404,9 +418,7 @@ export async function GET(request: Request) { latency_ms, connection_id, api_key_id, - api_key_name, - combo_name, - requested_model + api_key_name FROM usage_history ${rawWhere} UNION ALL @@ -424,22 +436,19 @@ export async function GET(request: Request) { 0 as latency_ms, NULL as connection_id, NULL as api_key_id, - NULL as api_key_name, - NULL as combo_name, - NULL as requested_model + NULL as api_key_name FROM daily_usage_summary ${aggWhere} - )` + )` : `(SELECT timestamp, provider, model, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, service_tier, success, latency_ms, - connection_id, api_key_id, api_key_name, - combo_name, requested_model + connection_id, api_key_id, api_key_name FROM usage_history ${whereClause} - )`; + )`; // When using the unified source the WHERE filters are already embedded inside. // For the original whereClause-based queries that still reference usage_history directly @@ -1214,10 +1223,16 @@ export async function GET(request: Request) { const presetParams: Record = {}; // Build unified source for preset cost queries (same UNION logic as main query). - const presetNeedsAggregated = !presetSinceIso || presetSinceIso < rawCutoffIso; + // Aggregated rows are gated off when an api_key filter is active (leakage) and + // bounded strictly before the raw cutoff (overlap / double-count) — see main query. + const presetNeedsAggregated = + (!presetSinceIso || presetSinceIso < rawCutoffDate) && apiKeyIds.length === 0; const presetRawConds: string[] = []; - if (presetSinceIso) { + if (presetNeedsAggregated) { + presetRawConds.push("timestamp >= @presetRawCutoff"); + presetParams.presetRawCutoff = rawCutoffDate; + } else if (presetSinceIso) { presetRawConds.push("timestamp >= @presetSince"); presetParams.presetSince = presetSinceIso; } @@ -1234,6 +1249,8 @@ export async function GET(request: Request) { presetAggConds.push("date >= @presetSinceDate"); presetParams.presetSinceDate = presetSinceDate; } + presetAggConds.push("date < @presetRawCutoffDate"); + presetParams.presetRawCutoffDate = rawCutoffDate; const presetAggWhere = presetAggConds.length > 0 ? `WHERE ${presetAggConds.join(" AND ")}` : ""; @@ -1257,7 +1274,7 @@ export async function GET(request: Request) { FROM daily_usage_summary ${presetAggWhere} )` - : `(SELECT timestamp, provider, model, service_tier, + : `(SELECT timestamp, provider, model, service_tier, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning FROM usage_history diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d2add6bca1..a75806abeb 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -7288,5 +7288,41 @@ "policyLabel": "السياسة:", "resetIn": "إعادة تعيين في", "quotaTotal": "المجموع" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index e8060136dc..a179837ef2 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -7288,5 +7288,41 @@ "policyLabel": "Siyasət:", "resetIn": "sıfırlayın", "quotaTotal": "cəmi" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 454e854311..a0b3c950b3 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -7288,5 +7288,41 @@ "policyLabel": "Политика:", "resetIn": "нулиране в", "quotaTotal": "общо" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e726ebb1dd..f5b9f18ed9 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -7288,5 +7288,41 @@ "policyLabel": "নীতি:", "resetIn": "রিসেট করুন", "quotaTotal": "মোট" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 64a937e43b..64b16738d3 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -7288,5 +7288,41 @@ "policyLabel": "Zásady:", "resetIn": "resetovat", "quotaTotal": "celkem" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index a6e03fdc73..e3a4e0eff0 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politik:", "resetIn": "nulstilles ind", "quotaTotal": "i alt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index fd30604832..bd3c426d43 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -7288,5 +7288,41 @@ "policyLabel": "Richtlinie:", "resetIn": "Zurücksetzung in", "quotaTotal": "Gesamt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6536c49d1e..1b6dd03b2a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7295,5 +7295,41 @@ "policyLabel": "Policy:", "resetIn": "reset in", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2e877fc011..8471e8cd56 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -7288,5 +7288,41 @@ "policyLabel": "Política:", "resetIn": "restablecer en", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index a69ded5c11..bb99bdb8d6 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -7288,5 +7288,41 @@ "policyLabel": "سیاست:", "resetIn": "بازنشانی در", "quotaTotal": "کل" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 927e7ee30d..f6623e067c 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Käytäntö:", "resetIn": "nollata sisään", "quotaTotal": "yhteensä" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 79ccc194eb..4e5319ab06 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politique :", "resetIn": "réinitialiser dans", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 363e6bdda0..a964ff72c8 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -7288,5 +7288,41 @@ "policyLabel": "નીતિ:", "resetIn": "માં રીસેટ કરો", "quotaTotal": "કુલ" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 72993dbe95..75c30cc4bd 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -7288,5 +7288,41 @@ "policyLabel": "מדיניות:", "resetIn": "לאפס פנימה", "quotaTotal": "סך הכל" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 7de1e0112c..047fecbf2d 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -7288,5 +7288,41 @@ "policyLabel": "नीति:", "resetIn": "में रीसेट करें", "quotaTotal": "कुल" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 49f1ac5479..132779fe5d 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -7288,5 +7288,41 @@ "policyLabel": "Szabályzat:", "resetIn": "visszaállítani", "quotaTotal": "összesen" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 8603d2a8a2..38cfc2ff0e 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -7288,5 +7288,41 @@ "policyLabel": "Kebijakan:", "resetIn": "ulang masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index bea8d8dc16..54b0175c71 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -7288,5 +7288,41 @@ "policyLabel": "Kebijakan:", "resetIn": "ulang masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 71d4226e07..f346240dba 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politica:", "resetIn": "reimpostato", "quotaTotal": "totale" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c46830e8ca..cf03760967 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -7288,5 +7288,41 @@ "policyLabel": "ポリシー:", "resetIn": "リセットして", "quotaTotal": "合計" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ad7706730f..f9787ae2d6 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -7288,5 +7288,41 @@ "policyLabel": "정책:", "resetIn": "재설정", "quotaTotal": "합계" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 95b12dfae5..f32bdffd71 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -7288,5 +7288,41 @@ "policyLabel": "धोरण:", "resetIn": "मध्ये रीसेट करा", "quotaTotal": "एकूण" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 228396d6a6..5e0051c654 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -7288,5 +7288,41 @@ "policyLabel": "Dasar:", "resetIn": "set semula masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index ceb077a56b..02df59074e 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -7288,5 +7288,41 @@ "policyLabel": "Beleid:", "resetIn": "opnieuw instellen", "quotaTotal": "totaal" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index e2f741185c..456a05d145 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -7288,5 +7288,41 @@ "policyLabel": "Retningslinjer:", "resetIn": "tilbakestille inn", "quotaTotal": "totalt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 5413e5010d..47fd1f2267 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Patakaran:", "resetIn": "i-reset sa", "quotaTotal": "kabuuan" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 28d4f60390..528656674e 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -7288,5 +7288,41 @@ "policyLabel": "Polityka:", "resetIn": "zresetuj w", "quotaTotal": "łącznie" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index d4282a4d0a..a9c8cb1b88 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -347,9 +347,9 @@ "keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.", "done": "Pronto", "savePermissions": "Salvar Permissões", - "endpointRestrictions": "__MISSING__:Allowed Endpoints", - "allEndpointsAllowed": "__MISSING__:This key can access all API endpoints.", - "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", + "endpointRestrictions": "Endpoints Permitidos", + "allEndpointsAllowed": "Esta chave pode acessar todos os endpoints de API.", + "endpointsRestricted": "Restrito a {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.", "keyActive": "Chave Ativa", @@ -437,10 +437,7 @@ "filterTypeRestricted": "Restrita", "shownOf": "{shown} de {total} exibidas", "emptyFilterTitle": "Nenhuma chave corresponde aos filtros", - "emptyFilterClear": "Limpar filtros", - "endpointRestrictions": "Endpoints Permitidos", - "allEndpointsAllowed": "Esta chave pode acessar todos os endpoints de API.", - "endpointsRestricted": "Restrito a {count} endpoint{count, plural, one {} other {s}}." + "emptyFilterClear": "Limpar filtros" }, "auditLog": { "title": "Log de Auditoria", @@ -7291,5 +7288,41 @@ "chatIdHint": "O identificador numérico único ou nome de usuário público do chat/canal.", "tutorial": "Tutorial" } + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 980b42953a..e64052bc39 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -7288,5 +7288,41 @@ "policyLabel": "Política:", "resetIn": "redefinir em", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 86c028f3ce..ef75e5d735 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politica:", "resetIn": "resetează", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 0f444aa551..0c8075816f 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -7288,5 +7288,41 @@ "policyLabel": "Политика:", "resetIn": "сброс в", "quotaTotal": "всего" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index a4505e452a..1604ebfecc 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -7288,5 +7288,41 @@ "policyLabel": "Pravidlá:", "resetIn": "resetovať v", "quotaTotal": "celkom" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index d9da4bd318..5c05ae1729 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -7288,5 +7288,41 @@ "policyLabel": "Policy:", "resetIn": "återställ in", "quotaTotal": "totalt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 6183d59ca7..d7c2932023 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -7288,5 +7288,41 @@ "policyLabel": "Sera:", "resetIn": "weka upya", "quotaTotal": "jumla" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 06a56309be..287238bc6e 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -7288,5 +7288,41 @@ "policyLabel": "கொள்கை:", "resetIn": "மீட்டமை", "quotaTotal": "மொத்தம்" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6c9eb79777..e55f2aed11 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -7288,5 +7288,41 @@ "policyLabel": "విధానం:", "resetIn": "రీసెట్ చేయండి", "quotaTotal": "మొత్తం" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index afccf15c34..439dcec4a9 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -7288,5 +7288,41 @@ "policyLabel": "นโยบาย:", "resetIn": "รีเซ็ตใน", "quotaTotal": "ทั้งหมด" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index e5be891031..d21a36f54c 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politika:", "resetIn": "sıfırlamak", "quotaTotal": "toplam" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 1fa9bcfff3..0e7b99daf3 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -7288,5 +7288,41 @@ "policyLabel": "Політика:", "resetIn": "скинути в", "quotaTotal": "всього" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index dbdece1d6d..e1b025cdf7 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -7288,5 +7288,41 @@ "policyLabel": "پالیسی:", "resetIn": "میں دوبارہ ترتیب دیں", "quotaTotal": "کل" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 865059d249..f251af62b9 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Chính sách:", "resetIn": "đặt lại trong", "quotaTotal": "tổng cộng" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4824b775fa..abd9d56f8f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -7288,5 +7288,41 @@ "policyLabel": "政策:", "resetIn": "重置于", "quotaTotal": "总计" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 93c1d1d7e5..9d67fb9151 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -90,18 +90,27 @@ export async function cleanupUsageHistory(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; - try { - // Roll up rows that are about to be deleted into daily_usage_summary so that - // the analytics route can still surface historical data via the UNION query. - await rollupUsageHistoryBeforeDate(cutoffDateStr); - } catch (err: unknown) { - // Non-fatal: log but continue with deletion so cleanup still runs. - console.error("[Cleanup] Error rolling up usage_history before deletion:", err); + // Roll up rows that are about to be deleted into daily_usage_summary so that the + // analytics route can still surface historical data via the UNION query. The rollup + // uses the exact same day boundary as the DELETE below, so every deleted row + // is guaranteed to have been aggregated first. + // + // rollupUsageHistoryBeforeDate catches its own errors and reports them via the + // returned result, so we inspect that rather than relying on a thrown exception. + // If the rollup failed, abort the DELETE to avoid permanently losing raw usage data + // that was never aggregated. + const rollupResult = await rollupUsageHistoryBeforeDate(cutoffDateStr); + if (rollupResult.errors > 0) { + console.error( + "[Cleanup] Aborting usage_history deletion because the pre-delete rollup failed." + ); + result.errors += rollupResult.errors; + return result; } try { const stmt = db.prepare("DELETE FROM usage_history WHERE timestamp < ?"); - const runResult = stmt.run(cutoffISO); + const runResult = stmt.run(cutoffDateStr); result.deleted = runResult.changes; console.log( diff --git a/src/lib/db/migrations/076_create_plugins.sql b/src/lib/db/migrations/076_create_plugins.sql new file mode 100644 index 0000000000..c4bf8e3f03 --- /dev/null +++ b/src/lib/db/migrations/076_create_plugins.sql @@ -0,0 +1,31 @@ +-- 059: Plugin system tables +-- WordPress-style plugin management with lifecycle tracking + +CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL DEFAULT '1.0.0', + description TEXT, + author TEXT, + license TEXT DEFAULT 'MIT', + main TEXT NOT NULL DEFAULT 'index.js', + source TEXT NOT NULL DEFAULT 'local', + tags TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'installed' + CHECK (status IN ('installed', 'active', 'inactive', 'error')), + enabled INTEGER NOT NULL DEFAULT 0, + manifest TEXT NOT NULL, + config TEXT DEFAULT '{}', + config_schema TEXT DEFAULT '{}', + hooks TEXT DEFAULT '[]', + permissions TEXT DEFAULT '[]', + plugin_dir TEXT NOT NULL, + error_message TEXT, + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + activated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status); +CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled); +CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name); diff --git a/src/lib/db/plugins.ts b/src/lib/db/plugins.ts new file mode 100644 index 0000000000..e13aee1e42 --- /dev/null +++ b/src/lib/db/plugins.ts @@ -0,0 +1,195 @@ +/** + * Plugin DB module — CRUD operations for the plugins table. + * + * @module db/plugins + */ + +import { getDbInstance } from "./core"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("DB_PLUGINS"); + +// ── Types ── + +export interface PluginRow { + id: string; + name: string; + version: string; + description: string | null; + author: string | null; + license: string; + main: string; + source: string; + tags: string; // JSON array + status: "installed" | "active" | "inactive" | "error"; + enabled: number; // 0 | 1 + manifest: string; // JSON + config: string; // JSON + configSchema: string; // JSON + hooks: string; // JSON array + permissions: string; // JSON array + pluginDir: string; + errorMessage: string | null; + installedAt: string; + updatedAt: string; + activatedAt: string | null; +} + +export interface PluginCreateInput { + id: string; + name: string; + version: string; + description?: string; + author?: string; + license?: string; + main: string; + source?: string; + tags?: string[]; + status?: PluginRow["status"]; + enabled?: boolean; + manifest: Record; + config?: Record; + configSchema?: Record; + hooks?: string[]; + permissions?: string[]; + pluginDir: string; +} + +// ── Helpers ── + +function rowToPlugin(row: any): PluginRow { + return { + id: row.id, + name: row.name, + version: row.version, + description: row.description, + author: row.author, + license: row.license, + main: row.main, + source: row.source, + tags: row.tags, + status: row.status, + enabled: row.enabled, + manifest: row.manifest, + config: row.config, + configSchema: row.config_schema, + hooks: row.hooks, + permissions: row.permissions, + pluginDir: row.plugin_dir, + errorMessage: row.error_message, + installedAt: row.installed_at, + updatedAt: row.updated_at, + activatedAt: row.activated_at, + }; +} + +// ── CRUD ── + +export function insertPlugin(input: PluginCreateInput): PluginRow { + const db = getDbInstance(); + const now = new Date().toISOString(); + + db.prepare( + `INSERT INTO plugins ( + id, name, version, description, author, license, main, source, tags, + status, enabled, manifest, config, config_schema, hooks, permissions, + plugin_dir, installed_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.id, + input.name, + input.version, + input.description ?? null, + input.author ?? null, + input.license ?? "MIT", + input.main, + input.source ?? "local", + JSON.stringify(input.tags ?? []), + input.status ?? "installed", + input.enabled ? 1 : 0, + JSON.stringify(input.manifest), + JSON.stringify(input.config ?? {}), + JSON.stringify(input.configSchema ?? {}), + JSON.stringify(input.hooks ?? []), + JSON.stringify(input.permissions ?? []), + input.pluginDir, + now, + now + ); + + log.info("plugin.inserted", { id: input.id, name: input.name }); + const plugin = getPluginByName(input.name); + if (!plugin) { + throw new Error(`Failed to retrieve plugin '${input.name}' after insertion`); + } + return plugin; +} + +export function getPluginById(id: string): PluginRow | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM plugins WHERE id = ?").get(id); + return row ? rowToPlugin(row) : null; +} + +export function getPluginByName(name: string): PluginRow | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM plugins WHERE name = ?").get(name); + return row ? rowToPlugin(row) : null; +} + +export function listPlugins(status?: PluginRow["status"]): PluginRow[] { + const db = getDbInstance(); + const rows = status + ? db.prepare("SELECT * FROM plugins WHERE status = ? ORDER BY name").all(status) + : db.prepare("SELECT * FROM plugins ORDER BY name").all(); + return rows.map(rowToPlugin); +} + +export function updatePluginStatus( + name: string, + status: PluginRow["status"], + errorMessage?: string +): boolean { + const db = getDbInstance(); + const now = new Date().toISOString(); + const activatedAt = status === "active" ? now : null; + + const result = db + .prepare( + `UPDATE plugins SET status = ?, enabled = ?, error_message = ?, + updated_at = ?, activated_at = COALESCE(?, activated_at) + WHERE name = ?` + ) + .run(status, status === "active" ? 1 : 0, errorMessage ?? null, now, activatedAt, name); + + if (result.changes > 0) { + log.info("plugin.status_updated", { name, status }); + } + return result.changes > 0; +} + +export function updatePluginConfig(name: string, config: Record): boolean { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const result = db + .prepare("UPDATE plugins SET config = ?, updated_at = ? WHERE name = ?") + .run(JSON.stringify(config), now, name); + + return result.changes > 0; +} + +export function deletePlugin(name: string): boolean { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM plugins WHERE name = ?").run(name); + if (result.changes > 0) { + log.info("plugin.deleted", { name }); + } + return result.changes > 0; +} + +export function pluginExists(name: string): boolean { + const db = getDbInstance(); + const row = db.prepare("SELECT 1 FROM plugins WHERE name = ?").get(name); + return !!row; +} diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts index 369a67a9f4..74b0aba0df 100644 --- a/src/lib/discovery/index.ts +++ b/src/lib/discovery/index.ts @@ -1,17 +1,26 @@ /** - * Discovery Service — Automated Provider Discovery + * Plugin Discovery Tool — Automated provider scanning. * - * Stub implementation for Phase 1. Scans LLM providers for free/unlimited - * access methods and reports findings. + * Scans LLM providers for free/unlimited access methods and reports findings. + * Integrated into OmniRoute as an opt-in service (default off). * - * Default: disabled (opt-in via settings) + * Phase 1: Stub with types and config. + * Phase 2: Full scanning engine. + * + * @module discovery */ +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("DISCOVERY"); + +// ── Types ── + export interface DiscoveryConfig { enabled: boolean; - scanInterval: number; + scanInterval: number; // ms between scans (default: 24h) maxConcurrentScans: number; - targetProviders: string[]; + targetProviders: string[]; // empty = scan all known notificationWebhook?: string; } @@ -31,16 +40,19 @@ export interface DiscoveryResult { verifiedAt?: string; } -const DEFAULT_CONFIG: DiscoveryConfig = { +// ── Default Config ── + +export const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = { enabled: false, scanInterval: 24 * 60 * 60 * 1000, // 24 hours maxConcurrentScans: 3, targetProviders: [], }; +// ── Probe ── + /** * Probe a single URL for API availability. - * Returns basic endpoint info if accessible. */ export async function probeEndpoint( url: string, @@ -62,16 +74,20 @@ export async function probeEndpoint( } } +// ── Scan ── + /** * Scan a provider for free access methods. - * Stub implementation — returns placeholder data. + * Phase 1 stub — returns placeholder. Phase 2 will implement real scanning. */ export async function scanProvider( providerId: string, _config: Partial = {} ): Promise { - // Phase 1 stub — returns empty results - // Phase 2 will implement actual scanning logic + log.info("discovery.scan_stub", { + providerId, + note: "Phase 1 stub — implement real scanning in Phase 2", + }); return [ { providerId, @@ -86,20 +102,20 @@ export async function scanProvider( ]; } +// ── Results ── + /** - * Get discovery results from the database. - * Stub implementation — returns empty array. + * Get discovery results. Phase 1 stub — returns empty array. */ export function getDiscoveryResults(_providerId?: string): DiscoveryResult[] { - // Phase 1 stub — Phase 2 will query SQLite return []; } +// ── Config ── + /** * Check if discovery service is enabled. */ export function isDiscoveryEnabled(): boolean { - return DEFAULT_CONFIG.enabled; + return DEFAULT_DISCOVERY_CONFIG.enabled; } - -export { DEFAULT_CONFIG }; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3ac0ca7847..9f3c51b906 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -527,3 +527,16 @@ export type { UpsertTokenLimitInput, TokenWindowState, } from "./db/tokenLimits"; + +export { + insertPlugin, + getPluginById, + getPluginByName, + listPlugins, + updatePluginStatus, + updatePluginConfig, + deletePlugin, + pluginExists, +} from "./db/plugins"; + +export type { PluginRow, PluginCreateInput } from "./db/plugins"; diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts new file mode 100644 index 0000000000..8bbcf7b357 --- /dev/null +++ b/src/lib/plugins/hooks.ts @@ -0,0 +1,265 @@ +/** + * Custom hook registry — event-driven plugin hook system. + * + * Plugins can register handlers for any OmniRoute event. Built-in events + * cover the full request lifecycle plus routing, rate limiting, and errors. + * + * @module plugins/hooks + */ + +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("PLUGIN_HOOKS"); + +// ── Types ── + +export type BlockingHookResult = { + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +}; + +export type HookHandler = ( + payload: unknown +) => void | Promise | BlockingHookResult | Promise; + +export interface HookRegistration { + pluginName: string; + handler: HookHandler; + priority: number; +} + +// ── Built-in events ── + +export const BUILTIN_EVENTS = [ + "onRequest", + "onResponse", + "onError", + "onModelSelect", + "onComboResolve", + "onRateLimit", + "onQuotaExhaust", + "onProviderError", + "onStreamStart", + "onStreamEnd", +] as const; + +export type BuiltinEvent = (typeof BUILTIN_EVENTS)[number]; + +// ── Registry ── + +const hooks: Map = new Map(); + +/** + * Register a handler for an event. + */ +export function registerHook( + event: string, + pluginName: string, + handler: HookHandler, + priority: number = 100 +): void { + if (!hooks.has(event)) { + hooks.set(event, []); + } + const list = hooks.get(event)!; + + // Prevent duplicate registration + if (list.some((r) => r.pluginName === pluginName && r.handler === handler)) { + return; + } + + list.push({ pluginName, handler, priority }); + list.sort((a, b) => a.priority - b.priority); + + log.info("hook.registered", { event, pluginName, priority }); +} + +/** + * Unregister all handlers for a plugin. + */ +export function unregisterHooks(pluginName: string): void { + for (const [event, list] of hooks.entries()) { + const before = list.length; + const filtered = list.filter((r) => r.pluginName !== pluginName); + if (filtered.length !== before) { + hooks.set(event, filtered); + log.info("hook.unregistered", { event, pluginName, removed: before - filtered.length }); + } + } +} + +/** + * Unregister a specific handler. + */ +export function unregisterHook(event: string, pluginName: string): void { + const list = hooks.get(event); + if (!list) return; + const before = list.length; + const filtered = list.filter((r) => r.pluginName !== pluginName); + hooks.set(event, filtered); + if (before !== filtered.length) { + log.info("hook.unregistered", { event, pluginName }); + } +} + +/** + * Emit an event — fire all registered handlers. + * Handler errors are logged but don't block other handlers. + */ +export async function emitHook(event: string, payload: unknown): Promise { + const list = hooks.get(event); + if (!list || list.length === 0) return; + + for (const reg of list) { + try { + await reg.handler(payload); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.handler_error", { + event, + pluginName: reg.pluginName, + error: message, + }); + } + } +} + +/** + * Emit a blocking event — fire handlers with body/metadata chaining. + * Returns blocking result from the first handler that blocks, or merged body/metadata. + * Used for onRequest and onResponse where plugins can modify or block the request. + */ +export async function emitHookBlocking( + event: string, + payload: unknown +): Promise<{ + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +}> { + const list = hooks.get(event) || []; + const ctx = (payload || {}) as Record; + let mergedBody: unknown = ctx.body; + let mergedMetadata: Record = (ctx.metadata as Record) || {}; + + for (const reg of list) { + try { + const result = await reg.handler(payload); + if (result && typeof result === "object") { + if ("body" in result) mergedBody = (result as Record).body; + if ("metadata" in result) + mergedMetadata = { + ...mergedMetadata, + ...(((result as Record).metadata as Record) || {}), + }; + if ("blocked" in result && (result as BlockingHookResult).blocked) { + return { + ...result, + body: (result as BlockingHookResult).body ?? mergedBody, + metadata: { ...mergedMetadata, ...((result as BlockingHookResult).metadata || {}) }, + }; + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.blocking_handler_error", { + event, + pluginName: reg.pluginName, + error: message, + }); + } + } + return { body: mergedBody, metadata: mergedMetadata }; +} + +// ── Lifecycle wrappers (for chatCore.ts convenience) ── + +export interface PluginContext { + requestId: string; + body: unknown; + model: string; + provider: string; + apiKeyInfo?: unknown; + metadata: Record; +} + +export interface PluginResult { + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +} + +// ── Plugin interface (for loader/manager compatibility) ── + +export interface Plugin { + name: string; + priority?: number; + enabled?: boolean; + onRequest?: (ctx: PluginContext) => Promise | PluginResult | void; + onResponse?: (ctx: PluginContext, response: unknown) => Promise | unknown | void; + onError?: (ctx: PluginContext, error: Error) => Promise | unknown | void; +} + +/** + * Run onRequest hooks — blocking. Plugins can modify body/metadata or block with 403. + */ +export async function runOnRequest(ctx: PluginContext): Promise { + return emitHookBlocking("onRequest", ctx); +} + +/** + * Run onResponse hooks — chains response through plugins. Each plugin can modify the response. + */ +export async function runOnResponse(ctx: PluginContext, response: unknown): Promise { + let currentResponse = response; + const list = hooks.get("onResponse") || []; + for (const reg of list) { + try { + const result = await reg.handler({ ...ctx, response: currentResponse }); + if ( + result !== undefined && + result !== null && + typeof result === "object" && + "response" in result + ) { + currentResponse = (result as { response: unknown }).response; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.response_handler_error", { pluginName: reg.pluginName, error: message }); + } + } + return currentResponse; +} + +/** + * Run onError hooks — fire-and-forget notification. + */ +export async function runOnError(ctx: PluginContext, error: Error): Promise { + await emitHook("onError", { ...ctx, error }); +} + +/** + * Get all registered hooks for an event. + */ +export function getHooks(event: string): HookRegistration[] { + return hooks.get(event) ?? []; +} + +/** + * Get all events that have registered handlers. + */ +export function getActiveEvents(): string[] { + return [...hooks.entries()].filter(([, list]) => list.length > 0).map(([event]) => event); +} + +/** + * Reset all hooks (for testing). + */ +export function resetHooks(): void { + hooks.clear(); +} diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts index 54de2269a4..43e7dd3dc2 100644 --- a/src/lib/plugins/index.ts +++ b/src/lib/plugins/index.ts @@ -15,6 +15,10 @@ // ── Types ── +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("PLUGINS"); + export interface PluginContext { /** Unique request ID */ requestId: string; @@ -75,9 +79,11 @@ export function registerPlugin(plugin: Plugin): void { _plugins.push(plugin); _plugins.sort((a, b) => (a.priority || 100) - (b.priority || 100)); - console.log( - `[Plugins] Registered "${plugin.name}" (priority: ${plugin.priority}, enabled: ${plugin.enabled})` - ); + log.info("plugin.registered", { + name: plugin.name, + priority: plugin.priority, + enabled: plugin.enabled, + }); } /** @@ -139,7 +145,7 @@ export async function runOnRequest( const result = await plugin.onRequest(currentCtx); if (result) { if (result.blocked) { - console.log(`[Plugins] Request blocked by "${plugin.name}"`); + log.info("plugin.request_blocked", { name: plugin.name }); return { blocked: true, response: result.response, ctx: currentCtx }; } if (result.body) currentCtx.body = result.body; @@ -148,7 +154,10 @@ export async function runOnRequest( } } } catch (err: any) { - console.error(`[Plugins] onRequest error in "${plugin.name}": ${err.message}`); + log.error("plugin.onRequest_error", { + name: plugin.name, + error: err instanceof Error ? err.message : String(err), + }); // Plugin errors don't block the pipeline by default } } @@ -171,7 +180,10 @@ export async function runOnResponse(ctx: PluginContext, response: any): Promise< currentResponse = modified; } } catch (err: any) { - console.error(`[Plugins] onResponse error in "${plugin.name}": ${err.message}`); + log.error("plugin.onResponse_error", { + name: plugin.name, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -189,11 +201,14 @@ export async function runOnError(ctx: PluginContext, error: Error): Promise void; +} + +// ── Plugin host script (runs in child process via fork) ── +// Uses process.send()/process.on("message") — NOT worker_threads. +// Written as .mjs to force ESM execution regardless of package.json. + +const PLUGIN_HOST_SCRIPT = ` +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); + +const pluginPath = process.argv[2]; +const plugin = await import(pluginPath); +const exports = plugin.default || plugin; + +// Send ready signal +process.send({ type: "ready", hooks: Object.keys(exports).filter(k => typeof exports[k] === "function") }); + +// Handle messages from parent +process.on("message", async (msg) => { + if (msg.type === "call") { + try { + const handler = exports[msg.hook]; + if (typeof handler !== "function") { + process.send({ type: "result", id: msg.id, error: "Hook not found" }); + return; + } + const result = await handler(msg.payload); + process.send({ type: "result", id: msg.id, result }); + } catch (err) { + process.send({ type: "result", id: msg.id, error: err.message }); + } + } +}); +`; + +/** + * Load a plugin in an isolated child process. + * Returns the plugin interface with hooks that communicate via IPC. + */ +export async function loadPlugin( + entryPoint: string, + manifest: PluginManifestWithDefaults +): Promise { + const permissions = manifest.requires.permissions; + const hostId = randomUUID(); + // .mjs extension forces ESM execution + const hostScriptPath = join(tmpdir(), `omniroute-plugin-host-${hostId}.mjs`); + + await writeFile(hostScriptPath, PLUGIN_HOST_SCRIPT, "utf-8"); + + const env: Record = { + ...getFilteredEnv(permissions), + PLUGIN_ENTRY: entryPoint, + PLUGIN_NAME: manifest.name, + }; + + const child = fork(hostScriptPath, [entryPoint], { + env, + stdio: ["pipe", "pipe", "pipe", "ipc"], + execArgv: ["--no-warnings"], + }); + + // Track pending calls with timeout support + const pendingCalls: Map< + string, + { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; + } + > = new Map(); + let callCounter = 0; + + child.on( + "message", + (msg: { type: string; id?: string; hooks?: string[]; result?: unknown; error?: string }) => { + if (msg.type === "ready") { + log.info("loader.process_ready", { name: manifest.name, hooks: msg.hooks }); + } else if (msg.type === "result" && msg.id) { + const pending = pendingCalls.get(msg.id); + if (pending) { + clearTimeout(pending.timer); + pendingCalls.delete(msg.id); + if (msg.error) { + pending.reject(new Error(msg.error)); + } else { + pending.resolve(msg.result); + } + } + } + } + ); + + child.on("error", (err) => { + log.error("loader.process_error", { name: manifest.name, error: err.message }); + }); + + child.on("exit", (code) => { + log.info("loader.process_exit", { name: manifest.name, code }); + for (const [, pending] of pendingCalls) { + clearTimeout(pending.timer); + pending.reject(new Error(`Plugin process exited with code ${code}`)); + } + pendingCalls.clear(); + rm(hostScriptPath, { force: true }).catch(() => {}); + }); + + // Call a hook in the child process with timeout + SIGTERM + SIGKILL escalation + const callHook = ( + hook: string, + payload: unknown, + timeout = DEFAULT_HOOK_TIMEOUT + ): Promise => { + return new Promise((resolve, reject) => { + const id = String(++callCounter); + const timer = setTimeout(() => { + pendingCalls.delete(id); + child.kill("SIGTERM"); + // Escalate to SIGKILL if plugin ignores SIGTERM + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, SIGKILL_GRACE_MS); + child.once("exit", () => clearTimeout(killTimer)); + reject(new Error(`Plugin hook '${hook}' timed out after ${timeout}ms`)); + }, timeout); + + pendingCalls.set(id, { resolve, reject, timer }); + child.send({ type: "call", id, hook, payload }); + }); + }; + + // Build Plugin interface + const plugin: Plugin = { + name: manifest.name, + priority: 100, + enabled: true, + }; + + plugin.onRequest = async (ctx: PluginContext): Promise => { + try { + const result = await callHook("onRequest", ctx); + return result as PluginResult | void; + } catch (err: unknown) { + log.error("plugin.onRequest_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise => { + try { + return await callHook("onResponse", { ctx, response }); + } catch (err: unknown) { + log.error("plugin.onResponse_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + plugin.onError = async (ctx: PluginContext, error: Error): Promise => { + try { + return await callHook("onError", { ctx, error: error.message }); + } catch (err: unknown) { + log.error("plugin.onError_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + log.info("loader.loaded", { + name: manifest.name, + hooks: ["onRequest", "onResponse", "onError"], + pid: child.pid, + }); + + const cleanup = () => { + child.kill("SIGTERM"); + // Escalate to SIGKILL after grace period + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, SIGKILL_GRACE_MS); + child.once("exit", () => clearTimeout(killTimer)); + rm(hostScriptPath, { force: true }).catch(() => {}); + log.info("loader.cleanup", { name: manifest.name }); + }; + + return { name: manifest.name, manifest, plugin, cleanup }; +} + +/** + * Filter environment variables based on permissions. + * Uses allowlist approach — only pass explicitly safe vars. + */ +function getFilteredEnv(permissions: Permission[]): Record { + const safeKeys = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "NODE_ENV"]; + const extendedSafeKeys = [...safeKeys, "PORT", "HOSTNAME", "TZ", "TMPDIR"]; + const allowedKeys = permissions.includes("env") ? extendedSafeKeys : safeKeys; + const env: Record = {}; + + for (const key of allowedKeys) { + if (process.env[key] !== undefined) env[key] = process.env[key]!; + } + + return env; +} diff --git a/src/lib/plugins/manager.ts b/src/lib/plugins/manager.ts new file mode 100644 index 0000000000..e83e159b11 --- /dev/null +++ b/src/lib/plugins/manager.ts @@ -0,0 +1,300 @@ +/** + * Plugin manager — lifecycle management for plugins. + * + * Singleton that coordinates scanner, loader, DB, and hook registry. + * Handles install, activate, deactivate, uninstall, scan, and startup loading. + * + * @module plugins/manager + */ + +import { mkdir, cp, rm, realpath, readFile } from "fs/promises"; +import { join, dirname } from "path"; +import { randomUUID } from "crypto"; +import { logger } from "../../../open-sse/utils/logger.ts"; +import { getDefaultPluginDir, scanPluginDir } from "./scanner"; +import { loadPlugin, type LoadedPlugin } from "./loader"; +import { registerHook, unregisterHooks } from "./hooks"; +import { + insertPlugin, + getPluginByName, + listPlugins as dbListPlugins, + updatePluginStatus, + deletePlugin as dbDeletePlugin, + pluginExists, + type PluginRow, +} from "../db/plugins"; +import type { PluginManifestWithDefaults } from "./manifest"; + +const log = logger("PLUGIN_MANAGER"); + +class PluginManager { + private static instance: PluginManager; + private loadedPlugins: Map = new Map(); + private pluginDir: string; + + private constructor() { + this.pluginDir = getDefaultPluginDir(); + } + + static getInstance(): PluginManager { + if (!PluginManager.instance) { + PluginManager.instance = new PluginManager(); + } + return PluginManager.instance; + } + + /** + * Install a plugin from a source directory. + * Copies to plugin dir, validates manifest, registers in DB. + */ + async install(sourceDir: string): Promise { + // Check if sourceDir itself contains plugin.json (direct plugin dir) + const { safeValidateManifest } = await import("./manifest"); + const { readFile: readFileFs } = await import("fs/promises"); + let directPlugin: { + name: string; + manifest: any; + pluginDir: string; + entryPoint: string; + } | null = null; + + try { + const manifestPath = join(sourceDir, "plugin.json"); + const raw = await readFileFs(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const result = safeValidateManifest(parsed); + if (result.success) { + const entryPoint = join(sourceDir, result.data.main); + directPlugin = { + name: result.data.name, + manifest: result.data, + pluginDir: sourceDir, + entryPoint, + }; + } + } catch {} + + const { plugins, errors } = directPlugin + ? { plugins: [directPlugin], errors: [] } + : await scanPluginDir(sourceDir); + + if (plugins.length === 0) { + throw new Error( + `No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}` + ); + } + + const discovered = plugins[0]; + const { name, manifest, pluginDir: srcDir } = discovered; + + // Check if already installed + if (pluginExists(name)) { + throw new Error(`Plugin '${name}' is already installed`); + } + + // Copy to plugin directory + const destDir = join(this.pluginDir, name); + await mkdir(dirname(destDir), { recursive: true }); + await cp(srcDir, destDir, { recursive: true }); + + // Register in DB + const row = insertPlugin({ + id: randomUUID(), + name, + version: manifest.version, + description: manifest.description, + author: manifest.author, + license: manifest.license, + main: manifest.main, + source: manifest.source, + tags: manifest.tags, + manifest: manifest as unknown as Record, + configSchema: manifest.configSchema as unknown as Record, + hooks: [ + manifest.hooks.onRequest && "onRequest", + manifest.hooks.onResponse && "onResponse", + manifest.hooks.onError && "onError", + ].filter(Boolean) as string[], + permissions: manifest.requires.permissions, + pluginDir: destDir, + enabled: manifest.enabledByDefault, + }); + + log.info("manager.installed", { name, version: manifest.version }); + + // Auto-activate if enabledByDefault + if (manifest.enabledByDefault) { + await this.activate(name); + } + + return row; + } + + /** + * Activate a plugin — load into VM, register hooks, update DB. + */ + async activate(name: string): Promise { + const row = getPluginByName(name); + if (!row) throw new Error(`Plugin '${name}' not found`); + if (row.status === "active") return; + + const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults; + + // Path traversal guard: use realpath to resolve symlinks + const entryPoint = join(row.pluginDir, manifest.main); + let resolvedPluginDir: string; + try { + resolvedPluginDir = await realpath(row.pluginDir); + } catch { + throw new Error(`Plugin directory '${row.pluginDir}' does not exist`); + } + const resolvedEntry = await realpath(entryPoint).catch(() => null); + if ( + !resolvedEntry || + (!resolvedEntry.startsWith(resolvedPluginDir + "/") && resolvedEntry !== resolvedPluginDir) + ) { + throw new Error(`Plugin '${name}' entry point escapes plugin directory`); + } + + try { + const loaded = await loadPlugin(entryPoint, manifest); + + // Register hooks individually via registerHook + const hookNames = ["onRequest", "onResponse", "onError"] as const; + for (const hookName of hookNames) { + const handler = loaded.plugin[hookName]; + if (typeof handler === "function") { + registerHook(hookName, name, handler as (payload: unknown) => void | Promise); + } + } + + this.loadedPlugins.set(name, loaded); + updatePluginStatus(name, "active"); + + log.info("manager.activated", { name }); + } catch (err: any) { + updatePluginStatus(name, "error", err.message); + log.error("manager.activate_failed", { name, error: err.message }); + throw err; + } + } + + /** + * Deactivate a plugin — unregister hooks, update DB. + */ + async deactivate(name: string): Promise { + const loaded = this.loadedPlugins.get(name); + if (loaded) { + unregisterHooks(name); + loaded.cleanup(); + this.loadedPlugins.delete(name); + } + + updatePluginStatus(name, "inactive"); + log.info("manager.deactivated", { name }); + } + + /** + * Uninstall a plugin — deactivate, delete directory, remove from DB. + */ + async uninstall(name: string): Promise { + const row = getPluginByName(name); + if (!row) throw new Error(`Plugin '${name}' not found`); + + // Deactivate first if active + if (row.status === "active") { + await this.deactivate(name); + } + + // Delete plugin directory + try { + await rm(row.pluginDir, { recursive: true, force: true }); + } catch (err: any) { + log.warn("manager.uninstall_dir_error", { name, error: err.message }); + } + + // Remove from DB + dbDeletePlugin(name); + log.info("manager.uninstalled", { name }); + } + + /** + * Scan plugin directory and sync with DB. + * Discovers new plugins and marks missing ones. + */ + async scan(): Promise<{ discovered: number; errors: Array<{ name: string; error: string }> }> { + const { plugins, errors } = await scanPluginDir(this.pluginDir); + + // Register newly discovered plugins that aren't in DB + for (const discovered of plugins) { + if (!pluginExists(discovered.name)) { + try { + insertPlugin({ + id: randomUUID(), + name: discovered.name, + version: discovered.manifest.version, + description: discovered.manifest.description, + author: discovered.manifest.author, + license: discovered.manifest.license, + main: discovered.manifest.main, + source: discovered.manifest.source, + tags: discovered.manifest.tags, + manifest: discovered.manifest as unknown as Record, + configSchema: discovered.manifest.configSchema as unknown as Record, + hooks: [ + discovered.manifest.hooks.onRequest && "onRequest", + discovered.manifest.hooks.onResponse && "onResponse", + discovered.manifest.hooks.onError && "onError", + ].filter(Boolean) as string[], + permissions: discovered.manifest.requires.permissions, + pluginDir: discovered.pluginDir, + enabled: discovered.manifest.enabledByDefault, + }); + } catch (err: any) { + errors.push({ name: discovered.name, error: `DB insert failed: ${err.message}` }); + } + } + } + + return { discovered: plugins.length, errors }; + } + + /** + * Load all active plugins on startup. + */ + async loadAll(): Promise { + const rows = dbListPlugins("active"); + log.info("manager.loadAll", { count: rows.length }); + + for (const row of rows) { + try { + await this.activate(row.name); + } catch (err: any) { + log.error("manager.loadAll_failed", { name: row.name, error: err.message }); + } + } + } + + /** + * Get a loaded plugin by name. + */ + getLoaded(name: string): LoadedPlugin | undefined { + return this.loadedPlugins.get(name); + } + + /** + * List all plugins from DB. + */ + listAll(): PluginRow[] { + return dbListPlugins(); + } + + /** + * Get plugin by name from DB. + */ + getPlugin(name: string): PluginRow | null { + return getPluginByName(name); + } +} + +export const pluginManager = PluginManager.getInstance(); diff --git a/src/lib/plugins/manifest.ts b/src/lib/plugins/manifest.ts new file mode 100644 index 0000000000..c3b5fb7815 --- /dev/null +++ b/src/lib/plugins/manifest.ts @@ -0,0 +1,129 @@ +/** + * Plugin manifest validator — Zod schema for plugin.json files. + * + * @module plugins/manifest + */ + +import { z } from "zod"; + +// ── Permission enum ── + +export const PermissionSchema = z.enum(["network", "file-read", "file-write", "env", "exec"]); +export type Permission = z.infer; + +// ── Skill definition in manifest ── + +export const ManifestSkillSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + input: z.record(z.string(), z.unknown()).optional(), + output: z.record(z.string(), z.unknown()).optional(), +}); +export type ManifestSkill = z.infer; + +// ── Config schema field ── + +export const ConfigFieldSchema = z.object({ + type: z.enum(["string", "number", "boolean", "select"]), + default: z.unknown().optional(), + min: z.number().optional(), + max: z.number().optional(), + enum: z.array(z.string()).optional(), + description: z.string().optional(), +}); +export type ConfigField = z.infer; + +// ── Hooks ── + +export const HooksSchema = z.object({ + onRequest: z.boolean().optional(), + onResponse: z.boolean().optional(), + onError: z.boolean().optional(), +}); + +// ── Requires ── + +export const RequiresSchema = z.object({ + omniroute: z.string().optional(), + permissions: z.array(PermissionSchema).optional(), +}); + +// ── Full manifest ── + +export const PluginManifestSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/, "Name must be kebab-case (lowercase, hyphens only)"), + version: z.string().regex(/^\d+\.\d+\.\d+$/, "Version must be semver (e.g. 1.0.0)"), + description: z.string().max(500).optional(), + author: z.string().max(200).optional(), + license: z.string().optional(), + main: z.string().optional(), + source: z.enum(["local", "marketplace"]).optional(), + tags: z.array(z.string()).optional(), + requires: RequiresSchema.optional(), + hooks: HooksSchema.optional(), + skills: z.array(ManifestSkillSchema).optional(), + enabledByDefault: z.boolean().optional(), + configSchema: z.record(z.string(), ConfigFieldSchema).optional(), +}); + +export type PluginManifest = z.infer; + +// ── Defaults applied after parsing ── + +export interface PluginManifestWithDefaults extends PluginManifest { + license: string; + main: string; + source: "local" | "marketplace"; + tags: string[]; + requires: { omniroute?: string; permissions: Permission[] }; + hooks: { onRequest: boolean; onResponse: boolean; onError: boolean }; + skills: ManifestSkill[]; + enabledByDefault: boolean; + configSchema: Record; +} + +export function applyDefaults(manifest: PluginManifest): PluginManifestWithDefaults { + return { + ...manifest, + license: manifest.license ?? "MIT", + main: manifest.main ?? "index.js", + source: manifest.source ?? "local", + tags: manifest.tags ?? [], + requires: { + omniroute: manifest.requires?.omniroute, + permissions: manifest.requires?.permissions ?? [], + }, + hooks: { + onRequest: manifest.hooks?.onRequest ?? false, + onResponse: manifest.hooks?.onResponse ?? false, + onError: manifest.hooks?.onError ?? false, + }, + skills: manifest.skills ?? [], + enabledByDefault: manifest.enabledByDefault ?? false, + configSchema: manifest.configSchema ?? {}, + }; +} + +// ── Validation ── + +export function validateManifest(raw: unknown): PluginManifestWithDefaults { + const parsed = PluginManifestSchema.parse(raw); + return applyDefaults(parsed); +} + +export function safeValidateManifest( + raw: unknown +): { success: true; data: PluginManifestWithDefaults } | { success: false; errors: string[] } { + const result = PluginManifestSchema.safeParse(raw); + if (result.success) { + return { success: true, data: applyDefaults(result.data) }; + } + return { + success: false, + errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`), + }; +} diff --git a/src/lib/plugins/scanner.ts b/src/lib/plugins/scanner.ts new file mode 100644 index 0000000000..b9beaf4000 --- /dev/null +++ b/src/lib/plugins/scanner.ts @@ -0,0 +1,110 @@ +/** + * Plugin scanner — discovers plugins from the filesystem. + * + * Scans ~/.omniroute/plugins/ for subdirectories containing plugin.json manifests. + * Returns validated manifests with directory paths. + * + * @module plugins/scanner + */ + +import { readdir, stat, readFile } from "fs/promises"; +import { join } from "path"; +import { logger } from "../../../open-sse/utils/logger.ts"; +import { safeValidateManifest, type PluginManifestWithDefaults } from "./manifest"; + +const log = logger("PLUGIN_SCANNER"); + +export interface DiscoveredPlugin { + name: string; + manifest: PluginManifestWithDefaults; + pluginDir: string; + entryPoint: string; +} + +/** + * Get the default plugin directory: ~/.omniroute/plugins/ + */ +export function getDefaultPluginDir(): string { + const home = process.env.HOME || process.env.USERPROFILE || "/tmp"; + return join(home, ".omniroute", "plugins"); +} + +/** + * Scan a directory for plugin subdirectories containing plugin.json. + * Skips hidden directories (.xxx) and non-directories. + */ +export async function scanPluginDir( + dir: string +): Promise<{ plugins: DiscoveredPlugin[]; errors: Array<{ name: string; error: string }> }> { + const plugins: DiscoveredPlugin[] = []; + const errors: Array<{ name: string; error: string }> = []; + + let entries: string[]; + try { + const dirEntries = await readdir(dir, { withFileTypes: true }); + entries = dirEntries + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name); + } catch (err: any) { + if (err.code === "ENOENT") { + log.info("scanner.dir_not_found", { dir }); + return { plugins: [], errors: [] }; + } + throw err; + } + + for (const entry of entries) { + const pluginDir = join(dir, entry); + const manifestPath = join(pluginDir, "plugin.json"); + + try { + const manifestStat = await stat(manifestPath); + if (!manifestStat.isFile()) { + errors.push({ name: entry, error: "plugin.json is not a file" }); + continue; + } + } catch { + errors.push({ name: entry, error: "no plugin.json found" }); + continue; + } + + try { + const raw = await readFile(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const result = safeValidateManifest(parsed); + + if (!result.success) { + const failResult = result as { success: false; errors: string[] }; + errors.push({ name: entry, error: `invalid manifest: ${failResult.errors.join("; ")}` }); + continue; + } + + const manifest = result.data; + const entryPoint = join(pluginDir, manifest.main); + + // Verify entry point exists + try { + await stat(entryPoint); + } catch { + errors.push({ + name: entry, + error: `entry point not found: ${manifest.main}`, + }); + continue; + } + + plugins.push({ + name: manifest.name, + manifest, + pluginDir, + entryPoint, + }); + + log.info("scanner.discovered", { name: manifest.name, version: manifest.version }); + } catch (err: any) { + errors.push({ name: entry, error: `failed to read manifest: ${err.message}` }); + } + } + + return { plugins, errors }; +} diff --git a/src/lib/usage/aggregateHistory.ts b/src/lib/usage/aggregateHistory.ts index 9841ad1db5..b541540016 100644 --- a/src/lib/usage/aggregateHistory.ts +++ b/src/lib/usage/aggregateHistory.ts @@ -138,7 +138,7 @@ export async function rollupHourlyQuota( * The ON CONFLICT clause uses SUM so re-running is additive-safe: if a date already * has a partial rollup (e.g. from a previous partial cleanup), new rows accumulate. * - * @param beforeDate - ISO date string (YYYY-MM-DD). Rows strictly before this date are rolled up. + * @param beforeDate - ISO timestamp/date boundary. Rows strictly before this value are rolled up. * @returns Aggregation result with counts */ export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise { @@ -162,7 +162,7 @@ export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise< COALESCE(SUM(tokens_output), 0) as total_output_tokens, 0.0 as total_cost FROM usage_history - WHERE DATE(timestamp) < ? + WHERE timestamp < ? AND provider IS NOT NULL AND provider != '' AND model IS NOT NULL AND model != '' GROUP BY LOWER(provider), LOWER(model), DATE(timestamp) diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index b696173914..2965c1d8a4 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -627,6 +627,12 @@ const comboRuntimeConfigSchema = z failoverBeforeRetry: z.boolean().optional(), maxSetRetries: z.coerce.number().int().min(0).max(10).optional(), setRetryDelayMs: z.coerce.number().int().min(0).max(60000).optional(), + zeroLatencyOptimizationsEnabled: z.boolean().optional(), + hedging: z.boolean().optional(), + hedgeDelayMs: z.coerce.number().int().min(0).max(60000).optional(), + fallbackCompressionMode: compressionModeSchema.optional(), + fallbackCompressionThreshold: z.coerce.number().int().min(0).max(2_000_000).optional(), + predictiveTtftMs: z.coerce.number().int().min(0).max(300000).optional(), // Auto-Combo / LKGP Extensions candidatePool: z.array(z.string().min(1)).optional(), weights: scoringWeightsSchema.optional(), @@ -654,7 +660,29 @@ const comboRuntimeConfigSchema = z shadowRouting: shadowRoutingSchema.optional(), evalRouting: evalRoutingSchema.optional(), }) - .strict(); + .strict() + .superRefine((config, ctx) => { + if (config.zeroLatencyOptimizationsEnabled === true) return; + + const addZeroLatencyIssue = (path: string[]) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "zeroLatencyOptimizationsEnabled must be true to enable zero-latency combo features", + path, + }); + }; + + if (config.hedging === true) { + addZeroLatencyIssue(["hedging"]); + } + if (typeof config.predictiveTtftMs === "number" && config.predictiveTtftMs > 0) { + addZeroLatencyIssue(["predictiveTtftMs"]); + } + if (config.fallbackCompressionMode && config.fallbackCompressionMode !== "off") { + addZeroLatencyIssue(["fallbackCompressionMode"]); + } + }); const comboNameSchema = z .string() diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 9cefbab6bf..90c2efad0c 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize"; -import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; +import { HIDEABLE_SIDEBAR_ITEM_IDS, SIDEBAR_SECTIONS } from "@/shared/constants/sidebarVisibility"; import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies"; const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const; @@ -36,8 +36,18 @@ export const updateSettingsSchema = z.object({ hideEndpointNgrokTunnel: z.boolean().optional(), autoRefreshProviderQuota: z.boolean().optional(), autoRefreshProviderQuotaInterval: z.number().int().min(10).max(3600).optional(), + pinProviderQuotaToHome: z.boolean().optional(), + showQuickStartOnHome: z.boolean().optional(), + showProviderTopologyOnHome: z.boolean().optional(), + localOnlyManageScopeBypassEnabled: z.boolean().optional(), + localOnlyManageScopeBypassPrefixes: z.array(z.string().max(200)).optional(), debugMode: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), + sidebarSectionOrder: z + .array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]])) + .optional(), + sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(), + sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(), comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(), codexServiceTier: z .object({ diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index 910e1c8fd6..9f2eea1266 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -43,6 +43,44 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades max → high", ); }); +test("sanitizeReasoningEffortForProvider: OpenAI-compatible Gemini normalizes max → xhigh", () => { + const log = makeLog(); + const body = { + model: "gemini-3.1-pro-preview", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "openai-compatible-free1", + "gemini-3.1-pro-preview", + log + ); + assert.notEqual(result, body, "must return a new object when mutating"); + assert.equal((result as any).reasoning_effort, "xhigh"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /max → xhigh/.test(m)), + "logs the normalization" + ); +}); + +test("sanitizeReasoningEffortForProvider: nested OpenAI reasoning max normalizes to xhigh", () => { + const body = { + model: "gemini-3.1-pro-preview", + reasoning: { effort: "max", summary: "auto" }, + input: [], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "openai-compatible-free1", + "gemini-3.1-pro-preview", + null + ); + assert.equal((result as any).reasoning.effort, "xhigh"); + assert.equal((result as any).reasoning.summary, "auto", "other reasoning fields preserved"); + assert.equal((result as any).reasoning_effort, undefined); +}); + test("sanitizeReasoningEffortForProvider: claude preserves max for Opus/Sonnet and downgrades Haiku", () => { const sonnetBody = { model: "claude-sonnet-4-6", diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 65600fdb0a..69eac65af2 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -24,6 +24,11 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => { assert.equal(first.failoverBeforeRetry, true); assert.equal(first.maxSetRetries, 0); assert.equal(first.setRetryDelayMs, 2000); + assert.equal(first.zeroLatencyOptimizationsEnabled, false); + assert.equal(first.hedging, false); + assert.equal(first.fallbackCompressionMode, "lite"); + assert.equal(first.fallbackCompressionThreshold, 1000); + assert.equal(first.predictiveTtftMs, 0); assert.equal(first.evalRouting.enabled, false); assert.equal(first.evalRouting.maxAgeHours, 720); @@ -142,6 +147,64 @@ test("updateComboDefaultsSchema accepts arbitrarily large timeout defaults and p assert.equal(parsed.providerOverrides.anthropic.targetTimeoutMs, 45000); }); +test("combo config schema accepts explicit zero-latency opt-in controls", () => { + const parsed = createComboSchema.parse({ + name: "zero-latency-opt-in", + models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], + config: { + zeroLatencyOptimizationsEnabled: true, + hedging: true, + hedgeDelayMs: 250, + fallbackCompressionMode: "lite", + fallbackCompressionThreshold: 2500, + predictiveTtftMs: 1800, + }, + }); + + assert.equal(parsed.config.zeroLatencyOptimizationsEnabled, true); + assert.equal(parsed.config.hedging, true); + assert.equal(parsed.config.hedgeDelayMs, 250); + assert.equal(parsed.config.fallbackCompressionMode, "lite"); + assert.equal(parsed.config.fallbackCompressionThreshold, 2500); + assert.equal(parsed.config.predictiveTtftMs, 1800); +}); + +test("combo config schema rejects enabled zero-latency subfeatures without opt-in", () => { + const result = createComboSchema.safeParse({ + name: "zero-latency-noop", + models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], + config: { + hedging: true, + fallbackCompressionMode: "lite", + predictiveTtftMs: 1800, + }, + }); + + assert.equal(result.success, false); + assert.deepEqual( + result.error.issues.map((issue) => issue.path.join(".")), + ["config.hedging", "config.predictiveTtftMs", "config.fallbackCompressionMode"] + ); +}); + +test("combo config schema allows zero-latency tuning fields when subfeatures stay disabled", () => { + const parsed = createComboSchema.parse({ + name: "zero-latency-disabled-tuning", + models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], + config: { + hedgeDelayMs: 250, + fallbackCompressionMode: "off", + fallbackCompressionThreshold: 2500, + predictiveTtftMs: 0, + }, + }); + + assert.equal(parsed.config.hedgeDelayMs, 250); + assert.equal(parsed.config.fallbackCompressionMode, "off"); + assert.equal(parsed.config.fallbackCompressionThreshold, 2500); + assert.equal(parsed.config.predictiveTtftMs, 0); +}); + test("resolveComboTargetTimeoutMs inherits the upstream timeout and only shortens it", () => { assert.equal(resolveComboTargetTimeoutMs({}, 600000), 600000); assert.equal(resolveComboTargetTimeoutMs({ targetTimeoutMs: 30000 }, 600000), 30000); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 88dc15e953..bfc730bebb 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -1331,6 +1331,174 @@ test("handleComboChat falls through generic 400s when a later priority target su assert.deepEqual(calls, ["provider-a/model-a", "provider-b/model-b"]); }); +test("handleComboChat preserves fallback request bodies when zero-latency optimizations are disabled", async () => { + const longToolOutput = "x".repeat(2500); + let fallbackBody: any = null; + + const result = await handleComboChat({ + body: { + messages: [ + { role: "user", content: "please inspect this output" }, + { role: "tool", content: longToolOutput }, + ], + }, + combo: { + name: "zero-latency-disabled-preserves-body", + strategy: "priority", + models: ["provider-a/model-a", "provider-b/model-b"], + config: { + maxRetries: 0, + retryDelayMs: 1, + fallbackCompressionMode: "lite", + fallbackCompressionThreshold: 1, + }, + }, + handleSingleModel: async (requestBody: any, modelStr: any) => { + if (modelStr === "provider-a/model-a") { + return errorResponse(500, "first target failed"); + } + fallbackBody = requestBody; + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null as any, + allCombos: null, + }); + + assert.equal(result.status, 200); + assert.equal(fallbackBody.messages[1].content, longToolOutput); +}); + +test("handleComboChat applies fallback compression only after explicit zero-latency opt-in", async () => { + const longToolOutput = "x".repeat(2500); + let fallbackBody: any = null; + + const result = await handleComboChat({ + body: { + messages: [ + { role: "user", content: "please inspect this output" }, + { role: "tool", content: longToolOutput }, + ], + }, + combo: { + name: "zero-latency-enabled-compresses-fallback", + strategy: "priority", + models: ["provider-a/model-a", "provider-b/model-b"], + config: { + maxRetries: 0, + retryDelayMs: 1, + zeroLatencyOptimizationsEnabled: true, + fallbackCompressionMode: "lite", + fallbackCompressionThreshold: 1, + }, + }, + handleSingleModel: async (requestBody: any, modelStr: any) => { + if (modelStr === "provider-a/model-a") { + return errorResponse(500, "first target failed"); + } + fallbackBody = requestBody; + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null as any, + allCombos: null, + }); + + assert.equal(result.status, 200); + const fallbackToolContent = fallbackBody.messages[1].content; + assert.equal(typeof fallbackToolContent, "string"); + assert.ok(fallbackToolContent.length < longToolOutput.length); + assert.match(fallbackToolContent, /truncated/i); +}); + +test("handleComboChat suppresses hedging unless zero-latency optimizations are enabled", async () => { + const calls: any[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "hedging-disabled-with-subfeature-set", + strategy: "priority", + models: ["model-a", "model-b"], + config: { + maxRetries: 0, + retryDelayMs: 1, + hedging: true, + hedgeDelayMs: 1, + }, + }, + handleSingleModel: async (_body: any, modelStr: any) => { + calls.push(modelStr); + await new Promise((resolve) => setTimeout(resolve, 20)); + return okResponse({ choices: [{ message: { content: modelStr } }] }); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null as any, + allCombos: null, + }); + + const payload = (await result.json()) as any; + + assert.equal(result.status, 200); + assert.equal(payload.choices[0].message.content, "model-a"); + assert.deepEqual(calls, ["model-a"]); +}); + +test("handleComboChat starts hedged fallback only after explicit zero-latency opt-in", async () => { + const calls: any[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "hedging-enabled-with-zero-latency", + strategy: "priority", + models: ["model-a", "model-b"], + config: { + maxRetries: 0, + retryDelayMs: 1, + zeroLatencyOptimizationsEnabled: true, + hedging: true, + hedgeDelayMs: 1, + }, + }, + handleSingleModel: async (_body: any, modelStr: any, target: any) => { + calls.push(modelStr); + if (modelStr === "model-a") { + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100); + target?.modelAbortSignal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(undefined); + }, + { once: true } + ); + }); + return okResponse({ choices: [{ message: { content: "slow" } }] }); + } + return okResponse({ choices: [{ message: { content: "fast" } }] }); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null as any, + allCombos: null, + }); + + const payload = (await result.json()) as any; + + assert.equal(result.status, 200); + assert.equal(payload.choices[0].message.content, "fast"); + assert.deepEqual(calls, ["model-a", "model-b"]); +}); + test("handleComboChat round-robin falls through generic 400s when a later model succeeds", async () => { const calls: any[] = []; @@ -2035,10 +2203,7 @@ test("handleComboChat standalone lkgp strategy updates LKGP after a successful c // Give the async fire-and-forget LKGP update a chance to execute let persistedProvider: any = null; for (let i = 0; i < 20; i++) { - persistedProvider = await settingsDb.getLKGP( - "standalone-lkgp-save", - "standalone-lkgp-save" - ); + persistedProvider = await settingsDb.getLKGP("standalone-lkgp-save", "standalone-lkgp-save"); if (persistedProvider?.provider === "openai") { break; } diff --git a/tests/unit/database-settings-maintenance.test.ts b/tests/unit/database-settings-maintenance.test.ts index fa553eb687..449ee5a4ea 100644 --- a/tests/unit/database-settings-maintenance.test.ts +++ b/tests/unit/database-settings-maintenance.test.ts @@ -186,3 +186,36 @@ test("usage aggregation upserts replace recomputed totals instead of adding them assert.equal(hourly.total_output_tokens, 10); assert.equal(hourly.total_cost, 0.75); }); + +test("cleanupUsageHistory rolls up and deletes old rows using the same day boundary", async () => { + const db = core.getDbInstance(); + const oldTimestamp = "2024-01-01T12:00:00.000Z"; + const recentTimestamp = new Date().toISOString(); + + databaseSettings.updateDatabaseSettings({ + retention: { + ...databaseSettings.getUserDatabaseSettings().retention, + usageHistory: 30, + }, + }); + + const insertUsage = db.prepare( + `INSERT INTO usage_history (provider, model, timestamp, tokens_input, tokens_output, success, latency_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + insertUsage.run("openai", "gpt-test", oldTimestamp, 100, 40, 1, 200); + insertUsage.run("openai", "gpt-test", recentTimestamp, 7, 3, 1, 100); + + const result = await cleanup.cleanupUsageHistory(); + + assert.equal(result.errors, 0); + assert.equal(result.deleted, 1); + + const remaining = db.prepare("SELECT COUNT(*) AS count FROM usage_history").get() as CountRow; + assert.equal(remaining.count, 1); + + const daily = db.prepare("SELECT * FROM daily_usage_summary").get() as UsageSummaryRow; + assert.equal(daily.total_requests, 1); + assert.equal(daily.total_input_tokens, 100); + assert.equal(daily.total_output_tokens, 40); +}); diff --git a/tests/unit/gemini-web.test.ts b/tests/unit/gemini-web.test.ts index f06fed6f51..492aba830e 100644 --- a/tests/unit/gemini-web.test.ts +++ b/tests/unit/gemini-web.test.ts @@ -74,3 +74,76 @@ test("Provider: gemini-web has correct models", async () => { assert.ok(modelIds.includes("gemini-2.0-pro")); assert.ok(modelIds.includes("gemini-2.0-flash")); }); + +// ─── Regression: #2832 — Playwright missing in Docker (runner-base) ────────── +// +// When the `runner-base` Docker image is used (no Playwright browsers installed), +// `import("playwright")` succeeds but `chromium.launch()` throws the well-known +// "Executable doesn't exist" error. The executor MUST surface this as a sanitized +// 500 — never an unhandled rejection — so users get a clear error message rather +// than a silent stream abort. +// +// Hard rule #12: error must go through sanitizeErrorMessage (no raw err.message +// or stack trace in the response body). + +test("#2832: Playwright launch failure returns sanitized 500, not unhandled rejection", async () => { + const playwrightError = new Error( + "browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/chromium_headless_shell-1161/chrome-linux/headless_shell\n" + + " at /app/node_modules/playwright-core/lib/server/browserType.js:123:19" + ); + + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + + playwright.chromium.launch = async () => { + throw playwrightError; + }; + + try { + const executor = new GeminiWebExecutor(); + const result = await executor.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hello" }], stream: false }, + stream: false, + credentials: { apiKey: "fake-cookie=abc" }, + signal: AbortSignal.timeout(5000), + log: null, + }); + + assert.equal(result.response.status, 500, "should return HTTP 500"); + const json = (await result.response.json()) as any; + assert.ok(typeof json.error === "string", "error field must be a string"); + // Hard rule #12: sanitizeErrorMessage must strip the stack trace tail. + assert.ok(!json.error.includes("\n at "), "must not contain multi-line stack trace"); + assert.ok(!json.error.includes("node_modules/playwright-core"), "must not contain node_modules source path"); + } finally { + playwright.chromium.launch = originalLaunch; + } +}); + +test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (integration path)", async () => { + // This test verifies the actual catch block in GeminiWebExecutor.execute() + // handles the Playwright "Executable doesn't exist" error shape correctly. + // We use an AbortSignal that is already aborted so we bypass the Playwright + // import entirely and hit the pre-launch abort check — confirming the executor + // returns a structured Response rather than throwing. + const executor = new GeminiWebExecutor(); + const controller = new AbortController(); + controller.abort(new Error("Request aborted")); + + const result = await executor.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hello" }], stream: false }, + stream: false, + credentials: { apiKey: "fake-cookie=abc" }, + signal: controller.signal, + log: null, + }); + + // Aborted request should return a structured 500, not throw + assert.ok(result.response instanceof Response, "must return a Response object"); + assert.equal(result.response.status, 500, "aborted request returns 500"); + const json = (await result.response.json()) as any; + assert.ok(typeof json.error === "string", "error must be a string"); + assert.ok(!json.error.includes("at /"), "no stack trace path in error response"); +}); diff --git a/tests/unit/home-page-pin-settings-schema.test.ts b/tests/unit/home-page-pin-settings-schema.test.ts new file mode 100644 index 0000000000..ed7c2b2fa4 --- /dev/null +++ b/tests/unit/home-page-pin-settings-schema.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { updateSettingsSchema } from "../../src/shared/validation/settingsSchemas.ts"; + +test("home page pin settings are accepted by the settings PATCH schema", () => { + const validation = updateSettingsSchema.safeParse({ + pinProviderQuotaToHome: true, + showQuickStartOnHome: false, + showProviderTopologyOnHome: true, + }); + + assert.equal(validation.success, true); + if (!validation.success) return; + assert.equal(validation.data.pinProviderQuotaToHome, true); + assert.equal(validation.data.showQuickStartOnHome, false); + assert.equal(validation.data.showProviderTopologyOnHome, true); +}); + +test("home page pin settings default to undefined when not provided", () => { + const validation = updateSettingsSchema.safeParse({}); + + assert.equal(validation.success, true); + if (!validation.success) return; + assert.equal(validation.data.pinProviderQuotaToHome, undefined); + assert.equal(validation.data.showQuickStartOnHome, undefined); + assert.equal(validation.data.showProviderTopologyOnHome, undefined); +}); + +test("home page pin settings reject non-boolean values", () => { + const validation = updateSettingsSchema.safeParse({ + pinProviderQuotaToHome: "yes", + }); + + assert.equal(validation.success, false); +}); + +test("localOnlyManageScopeBypass settings are accepted by the settings PATCH schema", () => { + const validation = updateSettingsSchema.safeParse({ + localOnlyManageScopeBypassEnabled: true, + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"], + }); + + assert.equal(validation.success, true); + if (!validation.success) return; + assert.equal(validation.data.localOnlyManageScopeBypassEnabled, true); + assert.deepEqual(validation.data.localOnlyManageScopeBypassPrefixes, [ + "/api/mcp/", + "/api/cli-tools/runtime/", + ]); +}); + +test("localOnlyManageScopeBypassEnabled rejects non-boolean values", () => { + const validation = updateSettingsSchema.safeParse({ + localOnlyManageScopeBypassEnabled: "yes", + }); + + assert.equal(validation.success, false); +}); + +test("localOnlyManageScopeBypassPrefixes rejects non-array values", () => { + const validation = updateSettingsSchema.safeParse({ + localOnlyManageScopeBypassPrefixes: "/api/mcp/", + }); + + assert.equal(validation.success, false); +}); diff --git a/tests/unit/parse-model-defensive.test.ts b/tests/unit/parse-model-defensive.test.ts new file mode 100644 index 0000000000..e817cb61b3 --- /dev/null +++ b/tests/unit/parse-model-defensive.test.ts @@ -0,0 +1,32 @@ +/** + * #2463 — parseModel must not crash on a non-string truthy input. + * + * The NVIDIA NIM investigation (Part C) showed parseModel({}) threw + * `cleanStr.endsWith is not a function`: the `if (!modelStr)` guard only catches + * falsy values (null/undefined/""), so a truthy non-string (object/number/array + * — e.g. a malformed combo `modelStr` or providerSpecificData saved as an object + * by a UI bug) reached `cleanStr.endsWith("[1m]")` and crashed. Same class of bug + * as #2359 (combo modelStr) and the proxyFetch errCode crash. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseModel } from "../../open-sse/services/model.ts"; + +test("#2463 — parseModel returns a null result (no throw) for an object", () => { + assert.doesNotThrow(() => parseModel({} as never)); + const parsed = parseModel({} as never); + assert.equal(parsed.provider, null); + assert.equal(parsed.model, null); + assert.equal(parsed.isAlias, false); +}); + +test("#2463 — parseModel does not throw for number / array inputs", () => { + assert.doesNotThrow(() => parseModel(123 as never)); + assert.doesNotThrow(() => parseModel(["nvidia/foo"] as never)); +}); + +test("parseModel still parses a normal provider/model string unchanged", () => { + const parsed = parseModel("nvidia/openai/gpt-oss-120b"); + assert.equal(parsed.provider, "nvidia"); + assert.equal(parsed.model, "openai/gpt-oss-120b"); +}); diff --git a/tests/unit/plugins-loader.test.ts b/tests/unit/plugins-loader.test.ts new file mode 100644 index 0000000000..8fbe097e84 --- /dev/null +++ b/tests/unit/plugins-loader.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Loader uses child_process.fork() — we test the module exports and types, +// not actual fork behavior (that requires integration tests with real plugins). + +import type { LoadedPlugin } from "../../src/lib/plugins/loader.ts"; +import type { Plugin, PluginContext, PluginResult } from "../../src/lib/plugins/index.ts"; + +// ── Type checks ── + +test("LoadedPlugin interface has required fields", () => { + // Verify the type structure exists by checking the module exports + const mock: LoadedPlugin = { + name: "test", + manifest: { + name: "test", + version: "1.0.0", + license: "MIT", + main: "index.js", + source: "local", + tags: [], + requires: { permissions: [] }, + hooks: { onRequest: false, onResponse: false, onError: false }, + skills: [], + enabledByDefault: false, + configSchema: {}, + }, + plugin: { name: "test" }, + cleanup: () => {}, + }; + assert.equal(mock.name, "test"); + assert.equal(typeof mock.cleanup, "function"); +}); + +test("Plugin interface supports lifecycle hooks", () => { + const plugin: Plugin = { + name: "test", + onRequest: async (_ctx: PluginContext): Promise => { + return { blocked: false }; + }, + onResponse: async (_ctx: PluginContext, response: any) => response, + onError: async (_ctx: PluginContext, _error: Error) => null, + }; + assert.equal(typeof plugin.onRequest, "function"); + assert.equal(typeof plugin.onResponse, "function"); + assert.equal(typeof plugin.onError, "function"); +}); + +test("PluginContext has required fields", () => { + const ctx: PluginContext = { + requestId: "test-123", + body: { model: "gpt-4" }, + model: "gpt-4", + provider: "openai", + metadata: {}, + }; + assert.equal(ctx.requestId, "test-123"); + assert.equal(ctx.model, "gpt-4"); +}); + +test("PluginResult supports blocking", () => { + const blocked: PluginResult = { + blocked: true, + response: { error: "denied" }, + }; + assert.ok(blocked.blocked); + assert.deepEqual(blocked.response, { error: "denied" }); +}); + +test("PluginResult supports body modification", () => { + const modified: PluginResult = { + body: { model: "gpt-4-turbo" }, + metadata: { plugin: "model-switcher" }, + }; + assert.equal(modified.body.model, "gpt-4-turbo"); + assert.equal(modified.metadata?.plugin, "model-switcher"); +}); diff --git a/tests/unit/plugins-manager.test.ts b/tests/unit/plugins-manager.test.ts new file mode 100644 index 0000000000..acb14dffca --- /dev/null +++ b/tests/unit/plugins-manager.test.ts @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Manager is a singleton that depends on DB, scanner, and loader. +// We test the module structure and type contracts here. +// Full lifecycle tests require integration setup with SQLite. + +import { pluginManager } from "../../src/lib/plugins/manager.ts"; + +// ── Singleton ── + +test("pluginManager is a singleton", () => { + const a = pluginManager; + const b = pluginManager; + assert.strictEqual(a, b); +}); + +test("pluginManager has all lifecycle methods", () => { + assert.equal(typeof pluginManager.install, "function"); + assert.equal(typeof pluginManager.activate, "function"); + assert.equal(typeof pluginManager.deactivate, "function"); + assert.equal(typeof pluginManager.uninstall, "function"); + assert.equal(typeof pluginManager.scan, "function"); + assert.equal(typeof pluginManager.loadAll, "function"); + assert.equal(typeof pluginManager.getLoaded, "function"); + assert.equal(typeof pluginManager.listAll, "function"); + assert.equal(typeof pluginManager.getPlugin, "function"); +}); + +test("pluginManager.getLoaded returns undefined for unknown plugin", () => { + const result = pluginManager.getLoaded("nonexistent-plugin"); + assert.equal(result, undefined); +}); + +test("pluginManager.install throws for invalid directory", async () => { + await assert.rejects( + () => pluginManager.install("/nonexistent/path"), + (err: Error) => { + assert.ok(err.message.includes("No valid plugin found")); + return true; + } + ); +}); + +test("pluginManager.activate throws for unknown plugin", async () => { + await assert.rejects(() => pluginManager.activate("nonexistent-plugin")); +}); + +test("pluginManager.uninstall throws for unknown plugin", async () => { + await assert.rejects(() => pluginManager.uninstall("nonexistent-plugin")); +}); diff --git a/tests/unit/plugins-scanner.test.ts b/tests/unit/plugins-scanner.test.ts new file mode 100644 index 0000000000..2d9926f79d --- /dev/null +++ b/tests/unit/plugins-scanner.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, mkdir, rm } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { scanPluginDir, getDefaultPluginDir } from "../../src/lib/plugins/scanner.ts"; + +let tmpDir: string; + +test.beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "plugin-scan-test-")); +}); + +test.afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── getDefaultPluginDir ── + +test("getDefaultPluginDir returns ~/.omniroute/plugins", () => { + const dir = getDefaultPluginDir(); + assert.ok(dir.endsWith(".omniroute/plugins")); +}); + +// ── scanPluginDir ── + +test("returns empty for non-existent directory", async () => { + const result = await scanPluginDir("/nonexistent/path"); + assert.deepEqual(result.plugins, []); + assert.deepEqual(result.errors, []); +}); + +test("returns empty for empty directory", async () => { + const result = await scanPluginDir(tmpDir); + assert.deepEqual(result.plugins, []); + assert.deepEqual(result.errors, []); +}); + +test("skips hidden directories", async () => { + await mkdir(join(tmpDir, ".hidden")); + await writeFile( + join(tmpDir, ".hidden", "plugin.json"), + JSON.stringify({ name: "hidden", version: "1.0.0" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); +}); + +test("discovers valid plugin", async () => { + const pluginDir = join(tmpDir, "my-plugin"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "my-plugin", version: "1.0.0" }) + ); + await writeFile(join(pluginDir, "index.js"), "module.exports = {};"); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 1); + assert.equal(result.plugins[0].name, "my-plugin"); + assert.equal(result.plugins[0].manifest.version, "1.0.0"); +}); + +test("reports error for missing plugin.json", async () => { + await mkdir(join(tmpDir, "no-manifest")); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("no plugin.json")); +}); + +test("reports error for invalid manifest", async () => { + const pluginDir = join(tmpDir, "bad-manifest"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "BAD NAME!", version: "nope" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("invalid manifest")); +}); + +test("reports error for missing entry point", async () => { + const pluginDir = join(tmpDir, "no-entry"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "no-entry", version: "1.0.0", main: "missing.js" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("entry point not found")); +}); + +test("discovers multiple plugins", async () => { + for (const name of ["plugin-a", "plugin-b"]) { + const d = join(tmpDir, name); + await mkdir(d); + await writeFile(join(d, "plugin.json"), JSON.stringify({ name, version: "1.0.0" })); + await writeFile(join(d, "index.js"), "module.exports = {};"); + } + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 2); +}); diff --git a/tests/unit/proxyfetch-undici-retry.test.ts b/tests/unit/proxyfetch-undici-retry.test.ts index e78e9acd40..aef4e7cecc 100644 --- a/tests/unit/proxyfetch-undici-retry.test.ts +++ b/tests/unit/proxyfetch-undici-retry.test.ts @@ -78,6 +78,47 @@ test("retry-succeeds: undici fails once then succeeds, native fallback is NOT in assert.equal(await res.text(), "undici-retry-success"); }); +test("#2463 — undici error with a NON-STRING code must not crash on errCode.startsWith (NVIDIA NIM)", async () => { + // Real-world: the undici v8 dispatcher can throw an error whose `.code` is a + // number (system errno) rather than a string. The fallback guard checked only + // `errCode !== undefined` before calling `errCode.startsWith("UND_ERR")`, so a + // numeric code crashed with `errCode.startsWith is not a function` — surfaced to + // the user as `s.startsWith is not a function` and, crucially, the crash fired + // BEFORE the native fallback could run, so NVIDIA NIM failed "all the time". + // + // The message here contains "UND_ERR" (a retryable dispatcher error) but the + // `||` short-circuit only reaches the `msg.includes("UND_ERR")` clause if the + // earlier `errCode.startsWith(...)` clause does not throw first. + let undiciCalls = 0; + let nativeCalls = 0; + + const mockUndici = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + undiciCalls++; + const err = new Error("UND_ERR_SOCKET: other side closed") as Error & { code?: unknown }; + (err as { code?: unknown }).code = -111; // numeric code (e.g. ECONNREFUSED errno) + throw err; + }; + + const mockNative = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + nativeCalls++; + return new Response("native-fallback-body", { status: 200 }); + }; + + const res = await proxyFetch( + "https://integrate.api.nvidia.com/v1/chat/completions", + { method: "POST" }, + { undiciFetch: mockUndici, nativeFetch: mockNative } + ); + + assert.equal(undiciCalls, 2, "undici must retry once before falling back (UND_ERR is retryable)"); + assert.equal( + nativeCalls, + 1, + "native fallback must fire — a numeric error code must not crash on errCode.startsWith" + ); + assert.equal(await res.text(), "native-fallback-body"); +}); + test("does not retry when body is a ReadableStream (non-replayable body)", async () => { let undiciCalls = 0; let nativeCalls = 0; diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index 76ebb95554..f02ec82824 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -193,7 +193,9 @@ test("rate limit semaphore covers immediate acquire, timeout, cooldown drain and release(); release(); - assert.equal(rateLimitSemaphore.getStats()["model-a"].running, 0); + // #2903 (perf-ram) prunes idle gates when they reach zero running/queued, so the + // entry may be absent here — assert "no running slots" without assuming it persists. + assert.equal(rateLimitSemaphore.getStats()["model-a"]?.running ?? 0, 0); const heldRelease = await rateLimitSemaphore.acquire("model-b", { maxConcurrency: 1 }); const timeoutPromise = rateLimitSemaphore.acquire("model-b", { @@ -214,7 +216,7 @@ test("rate limit semaphore covers immediate acquire, timeout, cooldown drain and assert.equal(rateLimitSemaphore.getStats()["model-c"].queued, 1); const secondRelease = await secondPromise; secondRelease(); - assert.equal(rateLimitSemaphore.getStats()["model-c"].queued, 0); + assert.equal(rateLimitSemaphore.getStats()["model-c"]?.queued ?? 0, 0); const blockingRelease = await rateLimitSemaphore.acquire("model-d", { maxConcurrency: 1 }); const queuedPromise = rateLimitSemaphore.acquire("model-d", { diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 59905699ee..f7989d4b36 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -333,6 +333,75 @@ test("GET /api/usage/analytics includes cost by API key", async () => { assertClose(body.byApiKey[0].cost, body.summary.totalCost); }); +test("GET /api/usage/analytics does not double-count raw and aggregated rows", async () => { + const db = core.getDbInstance(); + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 30); + const olderDate = new Date(cutoffDate); + olderDate.setDate(olderDate.getDate() - 1); + const olderDateStr = olderDate.toISOString().split("T")[0]; + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "raw-current", 100, 50, 1, 200, today.toISOString()); + + const insertSummary = db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + insertSummary.run("openai", "gpt-4o", todayStr, 99, 9900, 9900, 0); + insertSummary.run("openai", "gpt-4o", olderDateStr, 1, 25, 10, 0); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=all") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 2); + assert.equal(body.summary.totalTokens, 185); +}); + +test("GET /api/usage/analytics omits global aggregates when filtering by API key", async () => { + const apiKey = await apiKeysDb.createApiKey("Scoped Key", "machine1234567890"); + const db = core.getDbInstance(); + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "openai", + "gpt-4o", + "scoped-conn", + apiKey.id, + "Scoped Key", + 100, + 50, + 1, + 200, + new Date().toISOString() + ); + + db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "2024-01-01", 99, 9900, 9900, 0); + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=all&apiKeyIds=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); + assert.equal(body.summary.totalTokens, 150); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); +}); + test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => { const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890"); await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" });