diff --git a/.env.example b/.env.example index fe0f342498..29cacfe8cb 100644 --- a/.env.example +++ b/.env.example @@ -418,6 +418,15 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128 # Do not include /v1; if included accidentally it will be normalized away. # OMNIROUTE_PUBLIC_BASE_URL=http://192.168.0.15:20128 +# Absolute provider plugin manifest URL advertised to sidecar clients. +# Used by: open-sse/config/providerPluginManifestUrl.ts. When unset, OmniRoute +# derives the URL from request origin or HOST/PORT using OMNIROUTE_PUBLIC_PROTOCOL. +# OMNIROUTE_PROVIDER_MANIFEST_URL=https://omniroute.example.com/api/v1/provider-plugin-manifest + +# Protocol used when deriving provider plugin manifest URLs without a request origin. +# Used by: open-sse/config/providerPluginManifestUrl.ts. Defaults to http. +# OMNIROUTE_PUBLIC_PROTOCOL=http + # Max wait time for an async chatgpt-web image to land via the celsius # WebSocket, in milliseconds. Default 180000 (3 minutes). Increase during # upstream queue-deep windows ("Lots of people are creating images right now"). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa725c8ac..27801ac2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) - **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. (thanks @KooshaPari) +- **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). (thanks @KooshaPari) ### 🔧 Bug Fixes diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index b8e32d204f..ba285566ed 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 2007, + "count": 2015, + "_rebaseline_2026_07_03_6007_sidecar_manifest": "2007->2015. PR #6007 (re-cut) adds sidecar/provider-manifest header wiring and the public manifest URL helper; the feature files themselves introduce 0 new complexity violations (check-complexity flags none in providerPluginManifestUrl.ts). The +8 vs the 2007 release baseline is inherited release/v3.8.44 drift absorbed at merge (check:complexity measures 2015 on the current release tip). Tighten via --update next cycle / at /generate-release Phase 0.", "_rebaseline_2026_07_02_v3844_ci_observed": "2006->2007 (+1). Local Ubuntu measures 2006 on this tree; the GitHub fast-gates runner measures 2007 (same local-vs-CI off-by-one already documented in _rebaseline_2026_06_26_v3838_release_fast_gate). Use the CI-observed value so the gate is deterministic where it actually runs.", "_rebaseline_2026_07_02_v3844_post_5939": "2003->2006 (+3). Inherited drift from the release/v3.8.44 merges after 3a3d618fe (#5809 audio translations et al.), surfaced by PR fix#5959: check:complexity measures 2006 on the pristine base (cbd08ef78) WITH AND WITHOUT this PR's one-line CLI change (verified by reverting the file and re-measuring) — the PR is complexity-net-zero. Tighten via --update next cycle.", "_rebaseline_2026_07_02_v3844_merge_burst": "1995->2003 (+8). Inherited v3.8.44 cycle drift surfaced by PR #5939: check:complexity measures 2003 on BOTH the pristine release tip (3a3d618fe) and this PR's merged HEAD — identical, so all +8 came from the 2026-07-02 merge burst into release/v3.8.44 (#5933 codex schema, #5950 OCR, #5904/#5920 combo, #6000/#6008 executor refactors, etc.) merged while the fast-gates queue was base-red (file-size #5933). PR #5939 itself was verified complexity-net-zero during its own CI cycle (DiscoveryPageClient refactored into hooks/sub-components to stay under max-lines-per-function). Tighten via --update next cycle.", diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 713a04d125..bc356242cc 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -265,6 +265,8 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. | | `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. | | `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and non-dashboard browser-origin validation (for example `/v1/chatgpt-web/image/`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. | +| `OMNIROUTE_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, OmniRoute derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. | +| `OMNIROUTE_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `OMNIROUTE_PROVIDER_MANIFEST_URL` is set. | | `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. | | `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. | | `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. | diff --git a/docs/reference/PROVIDER_PLUGIN_MANIFEST.md b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md index 69952fd672..9f91dc39ed 100644 --- a/docs/reference/PROVIDER_PLUGIN_MANIFEST.md +++ b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md @@ -16,6 +16,11 @@ executor code, OAuth defaults, headers, or process environment state. The same manifest is available over HTTP at `GET /api/v1/provider-plugin-manifest` for sidecars that run out-of-process. +OmniRoute advertises that URL to Bifrost and CLIProxyAPI via the +`X-OmniRoute-Provider-Manifest-Url` request header. Set +`OMNIROUTE_PROVIDER_MANIFEST_URL` when the sidecar needs a public or container +network URL instead of the local request origin. + ## Goal Move provider metadata toward a plugin contract so the hot request path can diff --git a/open-sse/config/providerPluginManifestUrl.ts b/open-sse/config/providerPluginManifestUrl.ts new file mode 100644 index 0000000000..c896770f30 --- /dev/null +++ b/open-sse/config/providerPluginManifestUrl.ts @@ -0,0 +1,26 @@ +export const PROVIDER_PLUGIN_MANIFEST_HEADER = "X-OmniRoute-Provider-Manifest-Url"; +export const PROVIDER_PLUGIN_MANIFEST_PATH = "/api/v1/provider-plugin-manifest"; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + +export function resolveProviderPluginManifestUrl(origin?: string | null): string { + const configured = process.env.OMNIROUTE_PROVIDER_MANIFEST_URL?.trim(); + if (configured) return configured; + + if (origin) { + return `${trimTrailingSlash(origin)}${PROVIDER_PLUGIN_MANIFEST_PATH}`; + } + + const host = process.env.HOST || "127.0.0.1"; + const port = process.env.PORT || process.env.DASHBOARD_PORT || process.env.API_PORT || "20128"; + const protocol = process.env.OMNIROUTE_PUBLIC_PROTOCOL || "http"; + return `${protocol}://${host}:${port}${PROVIDER_PLUGIN_MANIFEST_PATH}`; +} + +export function getProviderPluginManifestHeader(origin?: string | null): Record { + return { + [PROVIDER_PLUGIN_MANIFEST_HEADER]: resolveProviderPluginManifestUrl(origin), + }; +} diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index c4b2047f2a..07bece15c0 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -22,6 +22,7 @@ import { type ProviderCredentials, } from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { getProviderPluginManifestHeader } from "../config/providerPluginManifestUrl.ts"; import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; @@ -265,6 +266,7 @@ export class CliproxyapiExecutor extends BaseExecutor { const headers: Record = { "Content-Type": "application/json", + ...getProviderPluginManifestHeader(), }; if (key) { diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index 6d1c29b0a4..da8722d11c 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -32,6 +32,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts"; import { z } from "zod"; import { checkIpRateLimit, @@ -257,6 +258,7 @@ export async function POST(request: Request) { "Content-Type": "application/json", "x-relay-token-id": token.id, "x-relay-client-ip": clientIp, + ...getProviderPluginManifestHeader(new URL(request.url).origin), }; if (BIFROST_API_KEY) { upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`; diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 9cfd7fe3de..f674327f62 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -26,6 +26,7 @@ import { type BifrostRoutingConfig, } from "./routingBackend"; import { getProviderPluginManifestEntryForModel } from "@omniroute/open-sse/config/providerPluginManifestRegistry.ts"; +import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts"; import { finalizeReadableStream } from "./streamFinalizer"; import { clearBifrostFailure, @@ -74,6 +75,7 @@ async function forwardToBifrost( "Content-Type": "application/json", "x-relay-token-id": token.id, "x-relay-client-ip": clientIp, + ...getProviderPluginManifestHeader(new URL(request.url).origin), }; if (config.apiKey) { upstreamHeaders.Authorization = `Bearer ${config.apiKey}`; diff --git a/tests/unit/provider-plugin-manifest-url.test.ts b/tests/unit/provider-plugin-manifest-url.test.ts new file mode 100644 index 0000000000..e2bcbc307a --- /dev/null +++ b/tests/unit/provider-plugin-manifest-url.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PROVIDER_PLUGIN_MANIFEST_HEADER, + resolveProviderPluginManifestUrl, + getProviderPluginManifestHeader, +} from "../../open-sse/config/providerPluginManifestUrl.ts"; + +test("provider manifest URL uses explicit env override", () => { + const previous = process.env.OMNIROUTE_PROVIDER_MANIFEST_URL; + process.env.OMNIROUTE_PROVIDER_MANIFEST_URL = "http://sidecar.local/manifest.json"; + try { + assert.equal( + resolveProviderPluginManifestUrl("http://127.0.0.1:20128"), + "http://sidecar.local/manifest.json", + ); + } finally { + if (previous === undefined) { + delete process.env.OMNIROUTE_PROVIDER_MANIFEST_URL; + } else { + process.env.OMNIROUTE_PROVIDER_MANIFEST_URL = previous; + } + } +}); + +test("provider manifest URL derives from request origin", () => { + assert.equal( + resolveProviderPluginManifestUrl("http://127.0.0.1:20128/"), + "http://127.0.0.1:20128/api/v1/provider-plugin-manifest", + ); +}); + +test("provider manifest header exposes stable header name", () => { + assert.deepEqual(getProviderPluginManifestHeader("http://localhost:20128"), { + [PROVIDER_PLUGIN_MANIFEST_HEADER]: + "http://localhost:20128/api/v1/provider-plugin-manifest", + }); +});