Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 38 additions & 38 deletions config/quality/complexity-baseline.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/reference/PROVIDER_PLUGIN_MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ 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.
Code that needs to consume the manifest over HTTP should use
`open-sse/config/providerPluginManifestClient.ts` instead of hard-coding the
route. The client resolves `OMNIROUTE_PROVIDER_MANIFEST_URL`, then an explicit
base URL, then the local OmniRoute API default.

OmniRoute advertises that URL to Bifrost and CLIProxyAPI via the
`X-OmniRoute-Provider-Manifest-Url` request header. Set
Expand Down
87 changes: 87 additions & 0 deletions open-sse/config/providerPluginManifestClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type {
ProviderPluginManifest,
ProviderPluginManifestEntry,
} from "./providerPluginManifest.ts";

export const PROVIDER_PLUGIN_MANIFEST_PATH = "/api/v1/provider-plugin-manifest";
export const PROVIDER_PLUGIN_MANIFEST_ENV = "OMNIROUTE_PROVIDER_MANIFEST_URL";

export interface ProviderPluginManifestClientOptions {
baseUrl?: string | null;
manifestUrl?: string | null;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}

function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

export function resolveProviderPluginManifestUrl(
options: Pick<ProviderPluginManifestClientOptions, "baseUrl" | "manifestUrl"> = {},
): string {
const explicitUrl = options.manifestUrl?.trim();
if (explicitUrl) return explicitUrl;

const envUrl = process.env[PROVIDER_PLUGIN_MANIFEST_ENV]?.trim();
if (envUrl) return envUrl;

const baseUrl = options.baseUrl?.trim();
if (baseUrl) {
return `${trimTrailingSlash(baseUrl)}${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 async function fetchProviderPluginManifest(
options: ProviderPluginManifestClientOptions = {},
): Promise<ProviderPluginManifest> {
const fetcher = options.fetchImpl ?? fetch;
const url = resolveProviderPluginManifestUrl(options);
const response = await fetcher(url, {
headers: { Accept: "application/json" },
signal: options.signal ?? undefined,
});

if (!response.ok) {
throw new Error(`Provider plugin manifest request failed: HTTP ${response.status}`);
}

const manifest = (await response.json()) as ProviderPluginManifest;
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.providers)) {
throw new Error("Provider plugin manifest response is not schemaVersion 1");
}

return manifest;
}

export function getProviderPluginManifestEntryForModelFromManifest(
manifest: ProviderPluginManifest,
model: string | undefined,
): ProviderPluginManifestEntry | null {
if (!model) return null;

const providerPrefix = model.includes("/") ? model.split("/", 1)[0] : "";
if (providerPrefix) {
const prefixed = manifest.providers.find(
(provider) => provider.id === providerPrefix || provider.alias === providerPrefix,
);
if (prefixed) return prefixed;
}

return manifest.providers.find((provider) =>
provider.models.some((candidate) => candidate.id === model),
) ?? null;
}

export async function fetchProviderPluginManifestEntryForModel(
model: string | undefined,
options: ProviderPluginManifestClientOptions = {},
): Promise<ProviderPluginManifestEntry | null> {
const manifest = await fetchProviderPluginManifest(options);
return getProviderPluginManifestEntryForModelFromManifest(manifest, model);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
"test:heap": "node --expose-gc --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/integration/heap-growth.test.ts",
"test:chaos": "cross-env RUN_CHAOS_INT=1 node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/resilience-chaos.test.ts",
"test:e2e": "node scripts/dev/run-playwright-tests.mjs test tests/e2e/*.spec.ts",
"test:fingerprint:e2e": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --test --test-force-exit --test-concurrency=1 tests/e2e/fingerprint-expansion.test.ts",
"test:protocols:e2e": "node scripts/dev/run-protocol-clients-tests.mjs",
"test:vitest": "vitest run --config vitest.mcp.config.ts",
"test:vitest:ui": "vitest run --config vitest.config.ts tests/unit/ui",
Expand Down
9 changes: 8 additions & 1 deletion scripts/check/check-complexity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const BASELINE_PATH = path.resolve(
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
const ESLINT_BIN = path.join(ROOT, "node_modules/eslint/bin/eslint.js");
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
Expand Down Expand Up @@ -52,9 +54,14 @@ export function evaluateComplexity(current, baseline) {
function measureComplexityCount() {
let stdout;
try {
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
const command = fs.existsSync(ESLINT_BIN) ? process.execPath : NPX_BIN;
const args = fs.existsSync(ESLINT_BIN)
? [ESLINT_BIN, ...ESLINT_ARGS.slice(1)]
: ["--yes", ...ESLINT_ARGS];
stdout = execFileSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
shell: !fs.existsSync(ESLINT_BIN) && process.platform === "win32",
});
} catch (err) {
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
Expand Down
2 changes: 2 additions & 0 deletions scripts/check/check-test-discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export const COLLECTORS = [
{ glob: "tests/integration/combo-live/*.live.test.ts", sources: ["package.json"] },
// Node native runner — test:system
{ glob: "tests/e2e/system-failover.test.ts", sources: ["package.json"] },
// Node native runner — test:fingerprint:e2e
{ glob: "tests/e2e/fingerprint-expansion.test.ts", sources: ["package.json"] },
// vitest.mcp.config.ts — test:vitest
{ glob: "open-sse/mcp-server/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "open-sse/services/autoCombo/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
Expand Down
3 changes: 1 addition & 2 deletions src/app/(dashboard)/dashboard/HomePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1092,8 +1092,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<p className="text-text-muted mt-0.5">
{t.rich("step1Desc", {
endpoint: (chunks) => (
<Link
href="/dashboard/api-manager"
<Link href="/dashboard/api-manager"
className="text-primary hover:underline"
>
{chunks}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,94 +1,95 @@
"use client";

/**
* CoolingConnectionsPanel — Dashboard readout of connections currently in a
* persisted 429 cooldown. Sourced from `useProviderConnections().connections`
* filtered on `rateLimitedUntil`. Live human-readable countdown via the
* client-safe `formatResetCountdown` helper in `@/shared/utils/formatting`.
*
* Why this exists: Fix A (per-account 429 cascade not persisting) writes the
* cooldown to `provider_connections.rate_limited_until` so the cascade
* survives the request boundary and process restart. Without a visible
* indicator the user has no way to see "OmniRoute learned that this key is
* exhausted — and for how long". This panel makes the lesson visible.
*
* Acceptance criteria (Issue #1, fix scope D):
* 1. Filters `connections` to those with a future `rateLimitedUntil`.
* 2. Shows connection name + reset countdown.
* 3. Re-evaluates every second so countdowns tick down.
* 4. Renders nothing when no connection is cooling.
* 5. Uses the same connection-shape type as ConnectionRow so the data flow
* stays consistent with the rest of the dashboard.
*/

import { useEffect, useState } from "react";
import { formatResetCountdown } from "@/shared/utils/formatting";
"use client";

/**
* CoolingConnectionsPanel — Dashboard readout of connections currently in a
* persisted 429 cooldown. Sourced from `useProviderConnections().connections`
* filtered on `rateLimitedUntil`. Live human-readable countdown via the
* existing `formatResetCountdown` helper re-exported by `@/lib/localDb`.
*
* Why this exists: Fix A (per-account 429 cascade not persisting) writes the
* cooldown to `provider_connections.rate_limited_until` so the cascade
* survives the request boundary and process restart. Without a visible
* indicator the user has no way to see "OmniRoute learned that this key is
* exhausted — and for how long". This panel makes the lesson visible.
*
* Acceptance criteria (Issue #1, fix scope D):
* 1. Filters `connections` to those with a future `rateLimitedUntil`.
* 2. Shows connection name + reset countdown.
* 3. Re-evaluates every second so countdowns tick down.
* 4. Renders nothing when no connection is cooling.
* 5. Uses the same connection-shape type as ConnectionRow so the data flow
* stays consistent with the rest of the dashboard.
*/

import { useEffect, useState } from "react";
import Card from "@/shared/components/Card";
import { formatResetCountdown } from "@/lib/localDb";
import type { ConnectionRowConnection } from "./ConnectionRow";

export interface CoolingConnectionsPanelProps {
readonly connections: readonly ConnectionRowConnection[];
}

function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean {
if (!connection.rateLimitedUntil) return false;
const until = new Date(connection.rateLimitedUntil).getTime();
return Number.isFinite(until) && until > now;
}

export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {

function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean {
if (!connection.rateLimitedUntil) return false;
const until = new Date(connection.rateLimitedUntil).getTime();
return Number.isFinite(until) && until > now;
}

export default function CoolingConnectionsPanel(
props: CoolingConnectionsPanelProps,
) {
const { connections } = props;
// Tick once per second so the human-readable countdown updates.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);

const cooling = connections.filter((c) => isCoolingNow(c, now));
if (cooling.length === 0) return null;

return (
<div
data-testid="cooling-connections-panel"
className="mb-4 rounded-card border border-amber-500/40 bg-amber-500/5 p-4 shadow-sm"
>
<div className="mb-2 flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
/>
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
Currently cooling ({cooling.length})
</h3>
</div>
<p className="mb-3 text-xs text-muted-foreground">
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
them until the timer expires — no manual disable required.
</p>
<ul className="space-y-1">
{cooling.map((c) => {
const until = c.rateLimitedUntil!;
const label =
c.displayName ||
c.name ||
c.email ||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
return (
<li
key={c.id ?? label}
className="flex items-center justify-between rounded border border-amber-500/30 bg-background/40 px-3 py-2 text-sm"
>
<span className="font-medium">{label}</span>
<span
className="font-mono text-xs text-amber-700 dark:text-amber-300"
data-testid="cooling-countdown"
>
{formatResetCountdown(until)}
</span>
</li>
);
})}
</ul>
</div>
);
// Tick once per second so the human-readable countdown updates.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);

const cooling = connections.filter((c) => isCoolingNow(c, now));
if (cooling.length === 0) return null;

return (
<Card
data-testid="cooling-connections-panel"
className="mb-4 border-amber-500/40 bg-amber-500/5 p-4"
>
<div className="mb-2 flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
/>
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
Currently cooling ({cooling.length})
</h3>
</div>
<p className="mb-3 text-xs text-muted-foreground">
These connections returned a 429 (rate-limit) on their last request.
OmniRoute will skip them until the timer expires — no manual disable
required.
</p>
<ul className="space-y-1">
{cooling.map((c) => {
const until = c.rateLimitedUntil!;
const label =
c.displayName || c.name || c.email || (c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
return (
<li
key={c.id ?? label}
className="flex items-center justify-between rounded border border-amber-500/30 bg-background/40 px-3 py-2 text-sm"
>
<span className="font-medium">{label}</span>
<span
className="font-mono text-xs text-amber-700 dark:text-amber-300"
data-testid="cooling-countdown"
>
{formatResetCountdown(until)}
</span>
</li>
);
})}
</ul>
</Card>
);
}
19 changes: 19 additions & 0 deletions tests/unit/dast-method-not-allowed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ test("raw HTTP guard rejects high-risk unsupported methods before Next.js handle
url: "/api/keys/0",
allow: "GET, PATCH, DELETE",
},
{
label: "key devices TRACE",
method: "TRACE",
url: "/api/keys/0/devices",
allow: "GET",
},
];

for (const testCase of cases) {
Expand Down Expand Up @@ -77,6 +83,10 @@ test("raw HTTP guard allows documented methods through", () => {
maybeHandleDisallowedMethod({ method: "OPTIONS", url: "/api/keys" }, response),
false
);
assert.equal(
maybeHandleDisallowedMethod({ method: "GET", url: "/api/keys/0/devices" }, response),
false
);
assert.equal(
maybeHandleDisallowedMethod({ method: "QUERY", url: "/api/health/ping" }, response),
false
Expand Down Expand Up @@ -122,6 +132,15 @@ test("OpenAPI documents high-risk route auth and setup responses", () => {
assert.match(apiKeyDetail, /"401":\n\s+description: Authentication required/);
assert.match(apiKeyDetail, /"404":\n\s+description: Key not found/);

const apiKeyDevicesStart = spec.indexOf(" /api/keys/{id}/devices:");
const apiKeyDevicesEnd = spec.indexOf("\n /api/settings/purge-usage-history:", apiKeyDevicesStart);
const apiKeyDevices = spec.slice(apiKeyDevicesStart, apiKeyDevicesEnd);
assert.match(apiKeyDevices, /\n get:/);
assert.match(
apiKeyDevices,
/"401":\n\s+\$ref: "#\/components\/responses\/ManagementAuthenticationRequired"/
);

const loginStart = spec.indexOf(" /api/auth/login:");
const loginEnd = spec.indexOf("\n /api/auth/logout:", loginStart);
const login = spec.slice(loginStart, loginEnd);
Expand Down
Loading