mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
Compare commits
1 Commits
fix/11060-
...
fix/sec-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1d786bd1 |
@@ -1 +0,0 @@
|
||||
- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: omni-webhooks
|
||||
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
|
||||
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
|
||||
---
|
||||
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
|
||||
|
||||
## Overview
|
||||
|
||||
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
|
||||
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -87,22 +87,6 @@ export function parseAlibabaModelStudioModelsForConnection(
|
||||
export function parseQwenCloudTextModels(data: any): any[] {
|
||||
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
|
||||
}
|
||||
|
||||
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed ids like
|
||||
// "anthropic/claude-fable-5"), but chat requests always go to the classic
|
||||
// /chat/completions endpoint, which only accepts the Sonar family. Filter
|
||||
// discovery to Sonar-family ids so agent-style ids never surface as routable
|
||||
// chat models (#11060). Bounded pattern — no ReDoS-prone quantifiers.
|
||||
export function parsePerplexitySonarModels(data: any): any[] {
|
||||
const models = Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data?.models)
|
||||
? data.models
|
||||
: [];
|
||||
return models.filter(
|
||||
(model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
|
||||
);
|
||||
}
|
||||
type ProviderModelsHeaderContext = {
|
||||
authType?: string;
|
||||
providerSpecificData?: unknown;
|
||||
@@ -675,17 +659,6 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
headers: { Accept: "application/json" },
|
||||
parseResponse: parseClinepassRecommendedModels,
|
||||
},
|
||||
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed agent
|
||||
// ids), but chat only accepts the Sonar family on /chat/completions. Import
|
||||
// must keep Sonar-family ids only (#11060).
|
||||
perplexity: {
|
||||
url: "https://api.perplexity.ai/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: parsePerplexitySonarModels,
|
||||
},
|
||||
cohere: {
|
||||
url: "https://api.cohere.com/v2/models",
|
||||
method: "GET",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
|
||||
|
||||
// Regression guard for #11060 — Perplexity's /v1/models endpoint lists the
|
||||
// Agent API catalog (vendor-prefixed ids like "anthropic/claude-fable-5"), but
|
||||
// chat requests always go to the classic /chat/completions endpoint, which only
|
||||
// accepts the Sonar family. Without a PROVIDER_MODELS_CONFIG entry, generic
|
||||
// model import pulled those agent-style ids into the connection's chat model
|
||||
// list and every routed request failed with 400 "Invalid model". The discovery
|
||||
// entry must exist and its parseResponse must keep only Sonar-family ids.
|
||||
|
||||
test("perplexity has a discovery entry in PROVIDER_MODELS_CONFIG", () => {
|
||||
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
|
||||
assert.ok(cfg, "expected a perplexity entry in PROVIDER_MODELS_CONFIG");
|
||||
assert.equal(cfg.method, "GET");
|
||||
assert.equal(cfg.url, "https://api.perplexity.ai/v1/models");
|
||||
assert.equal(typeof cfg.parseResponse, "function");
|
||||
});
|
||||
|
||||
test("perplexity parseResponse keeps only the Sonar family (#11060)", () => {
|
||||
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
|
||||
const models = cfg.parseResponse({
|
||||
object: "list",
|
||||
data: [
|
||||
{ id: "anthropic/claude-fable-5", object: "model", owned_by: "anthropic" },
|
||||
{ id: "sonar-pro", object: "model", owned_by: "perplexity" },
|
||||
{ id: "sonar", object: "model", owned_by: "perplexity" },
|
||||
],
|
||||
}) as Array<{ id: string }>;
|
||||
|
||||
assert.deepEqual(
|
||||
models.map((model) => model.id),
|
||||
["sonar-pro", "sonar"]
|
||||
);
|
||||
});
|
||||
|
||||
test("perplexity parseResponse keeps every Sonar variant and drops non-Sonar ids", () => {
|
||||
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
|
||||
const models = cfg.parseResponse({
|
||||
data: [
|
||||
{ id: "sonar-deep-research" },
|
||||
{ id: "sonar-reasoning-pro" },
|
||||
{ id: "sonar-pro" },
|
||||
{ id: "sonar" },
|
||||
{ id: "openai/gpt-5" },
|
||||
{ id: "sonarish" },
|
||||
],
|
||||
}) as Array<{ id: string }>;
|
||||
|
||||
assert.deepEqual(
|
||||
models.map((model) => model.id),
|
||||
["sonar-deep-research", "sonar-reasoning-pro", "sonar-pro", "sonar"]
|
||||
);
|
||||
});
|
||||
|
||||
test("perplexity parseResponse tolerates empty and malformed payloads", () => {
|
||||
const cfg = PROVIDER_MODELS_CONFIG.perplexity;
|
||||
assert.deepEqual(cfg.parseResponse({ data: [] }), []);
|
||||
assert.deepEqual(cfg.parseResponse(undefined), []);
|
||||
assert.deepEqual(cfg.parseResponse({}), []);
|
||||
});
|
||||
@@ -40,6 +40,10 @@ db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-adopt', 'stopped', 29996, 0, 0, 0)`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-adopt-deny', 'stopped', 29994, 0, 0, 0)`
|
||||
).run();
|
||||
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
@@ -213,6 +217,10 @@ test("does NOT auto-restart on crash", async () => {
|
||||
// the port, the supervisor ADOPTS it (marks running, no child spawned) instead
|
||||
// of spawning a duplicate that would die with EADDRINUSE.
|
||||
test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", async () => {
|
||||
// GHSA-wg9p-6m2g-4v27: adoption of an already-healthy listener is opt-in
|
||||
// (a squatter can answer 2xx), so this adoption-path test opts in explicitly.
|
||||
const prevAdopt = process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = "1";
|
||||
const healthServer = startHealthServer(29996);
|
||||
const cfg = { ...tickConfig("test-adopt", 29996), probeBeforeSpawn: true };
|
||||
const sup = new ServiceSupervisor(cfg);
|
||||
@@ -232,6 +240,8 @@ test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", as
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
if (prevAdopt === undefined) delete process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
else process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = prevAdopt;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,6 +264,9 @@ test("adopted service resolves and records the real pid of the process holding t
|
||||
// (#10523).
|
||||
const healthServer = startHealthServer(29995);
|
||||
const cfg = { ...tickConfig("test-adopt", 29995), probeBeforeSpawn: true };
|
||||
// Same opt-in as the adoption test above (GHSA-wg9p-6m2g-4v27).
|
||||
const prevAdopt = process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = "1";
|
||||
const sup = new ServiceSupervisor(cfg);
|
||||
|
||||
try {
|
||||
@@ -268,5 +281,33 @@ test("adopted service resolves and records the real pid of the process holding t
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
if (prevAdopt === undefined) delete process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
else process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = prevAdopt;
|
||||
}
|
||||
});
|
||||
|
||||
// GHSA-wg9p-6m2g-4v27: a healthy 2xx on the probed port no longer proves the
|
||||
// listener is this service — a local squatter can answer 200 and get adopted,
|
||||
// receiving the injected service API key. Without the operator opt-in the
|
||||
// supervisor must surface the actionable error instead of adopting.
|
||||
test("probeBeforeSpawn does NOT adopt a healthy listener without the opt-in", async () => {
|
||||
const healthServer = startHealthServer(29994);
|
||||
const cfg = { ...tickConfig("test-adopt-deny", 29994), probeBeforeSpawn: true };
|
||||
const prevAdopt = process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
delete process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
const sup = new ServiceSupervisor(cfg);
|
||||
|
||||
try {
|
||||
const status = await sup.start();
|
||||
assert.equal(status.state, "error", "a healthy listener is not adopted by default");
|
||||
assert.match(
|
||||
status.lastError ?? "",
|
||||
/OMNIROUTE_ADOPT_EXISTING_SERVICE/,
|
||||
"the error names the opt-in escape hatch"
|
||||
);
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
if (prevAdopt !== undefined) process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = prevAdopt;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user