Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
7b1d786bd1 test(services): align adoption tests with the opt-in contract from #11040
The #11040 merge changed decidePreSpawn() to opt-in adoption
(GHSA-wg9p-6m2g-4v27: a 2xx on the probed port cannot prove the listener
is this service), but the two integration tests in ServiceSupervisor.test.ts
still asserted adopt-by-default, leaving the release tip red:

- #6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)
- adopted service resolves and records the real pid of the process holding the port

Both now set OMNIROUTE_ADOPT_EXISTING_SERVICE=1 (restored in finally) so the
adoption path they exercise stays covered under the new contract. Adds a new
default-deny case asserting that without the flag a healthy listener is NOT
adopted and the error names the opt-in escape hatch.

Verified against base tip 6cd4d38e21: file is 8/8 green,
ninerouter-embed-port-6205.test.ts still 9/9, eslint + prettier clean.
2026-08-22 13:03:36 -03:00
5 changed files with 43 additions and 93 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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({}), []);
});

View File

@@ -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;
}
});