fix(providers): validate Zylo keys against the chat route, not its open catalog (#13877)

#13828. `zylo-api` is registered as OpenAI-compatible, so the generic probe
validated a key with `GET /v1/models` and returned on the first 2xx. Zylo serves
that route WITHOUT authentication — it answers 200 with no Authorization header
at all, and 200 for a bogus key — so the account-setup dialog greened any
string. The first request Zylo actually authenticates is the user's own model
test, which comes back `401 {"error":"Key not found: zk-…"}`.

Running the production validator against a fake key returned `{valid:true}`
before this change.

Two corrections to the report: nothing passes a key value where a key name is
expected — there is no such lookup — and that 401 text is Zylo's own, not
OmniRoute's. The defect is a false-green validation, which is worse: an invalid
key is stored as working and only fails later, at the model level.

`POST /v1/chat/completions` is authenticated, so a single probe there is the
correct auth check — the remedy already applied to dify (#11002) and bytez
(#5422). Registered under both `zylo-api` and the `zylo` alias, matching the
adobe-firefly/firefly pair, so a connection stored under the alias does not fall
back to the open-catalog probe.

Tests are red-first: a key the chat route rejects must not validate, the catalog
route must not be consulted at all, a key it accepts still validates, and the
alias takes the same path. The first and third failed before the fix.

Not in scope, reported separately: Zylo's catalog is not OpenAI-shaped
(`{text:[…],image:[…]}`), so model sync yields 0 models.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 13:35:33 -03:00
committed by GitHub
parent 309b635740
commit ba274b616a
4 changed files with 173 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(providers):** the Zylo API key check now probes the authenticated chat route instead of the open catalog — Zylo serves `GET /v1/models` without authentication, so the account-setup dialog accepted any string as valid and the key was only rejected later, when a model test returned Zylo's own `401 "Key not found: zk-…"` ([#13828](https://github.com/diegosouzapw/OmniRoute/issues/13828))

View File

@@ -108,6 +108,7 @@ import {
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
import { validateDifyProvider } from "./validation/dify";
import { validateZyloApiProvider } from "./validation/zylo";
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
import {
validateV0VercelProvider,
@@ -241,6 +242,16 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to
// its catalog, so the generic chat probe can't validate a fresh key.
bytez: validateBytezProvider,
// #13828: Zylo serves GET /v1/models WITHOUT authentication — 200 with no Authorization
// header, and 200 for a bogus key. The generic OpenAI-like probe returns on the first 2xx
// from that route, so the setup dialog greened any string and the user only discovered the
// key was rejected when their own model test came back `401 {"error":"Key not found: zk-…"}`.
// Probe the chat route, which is the one Zylo actually authenticates.
"zylo-api": validateZyloApiProvider,
// Registered under the alias too: connections are commonly stored as "zylo" (same prefix as
// the zylo/<model> routing ids), and the alias must not fall back to the open-catalog probe.
// Same shape as the adobe-firefly/firefly pair above.
zylo: validateZyloApiProvider,
deepgram: validateDeepgramProvider,
assemblyai: validateAssemblyAIProvider,
"rev-ai": validateRevAiProvider,

View File

@@ -0,0 +1,52 @@
/**
* Zylo key check. `zylo-api` is registered as an OpenAI-compatible provider, so the
* generic probe validates a key by calling `GET /v1/models` and returning `{valid:true}`
* on the first 2xx. Zylo serves that route WITHOUT authentication — it answers 200 with
* no `Authorization` header at all, and 200 for a bogus key — so the account-setup dialog
* greened any string and the first request Zylo actually authenticates was the user's own
* model test, which came back `401 {"error":"Key not found: zk-…"}` (#13828; that message
* is Zylo's own text, not OmniRoute's).
*
* `POST /v1/chat/completions` is the authenticated route, so a single probe there is the
* correct auth check — the same remedy already applied to dify (#11002) and bytez (#5422).
*/
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { normalizeBaseUrl } from "./urlHelpers";
import { buildBearerHeaders } from "./headers";
import { validateDirectChatProvider } from "./directChatProbe";
/** Zylo's catalog lists `gpt-oss`; kept overridable for accounts on a different plan. */
export const ZYLO_DEFAULT_VALIDATION_MODEL_ID = "gpt-oss";
/**
* Shape a provider/connection base URL into Zylo's chat route. Accepts the API root
* (`https://api.zyloai.net`), a `/v1` root, or a full `/v1/chat/completions` URL, and
* always returns `{base}/v1/chat/completions`.
*/
export function resolveZyloChatUrl(baseUrl: string) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return "";
const cleaned = normalized
.replace(/\/chat\/completions$/, "")
.replace(/\/models$/, "")
.replace(/\/v1$/, "");
return `${cleaned}/v1/chat/completions`;
}
export async function validateZyloApiProvider({ apiKey, providerSpecificData = {} }: any) {
const configuredBaseUrl =
normalizeBaseUrl(providerSpecificData.baseUrl) ||
getRegistryEntry("zylo-api")?.baseUrl ||
"https://api.zyloai.net/v1/chat/completions";
return validateDirectChatProvider({
url: resolveZyloChatUrl(configuredBaseUrl),
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: {
model: providerSpecificData.validationModelId || ZYLO_DEFAULT_VALIDATION_MODEL_ID,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
},
providerSpecificData,
});
}

View File

@@ -0,0 +1,109 @@
import test from "node:test";
import assert from "node:assert/strict";
import { after, before } from "node:test";
import { createServer, type Server } from "node:http";
import { validateProviderApiKey } from "../../src/lib/providers/validation.ts";
// #13828 — `zylo-api` is registered with format:"openai", so the generic OpenAI-like probe
// validates a key by calling GET /v1/models and returning `{valid:true}` on the first 2xx.
// Zylo serves that route WITHOUT authentication: it answers 200 with no Authorization header
// and 200 for a bogus key. The account-setup dialog therefore greens any string, and the first
// request that Zylo actually authenticates is the user's own model test, which comes back
// `401 {"error":"Key not found: zk-…"}` — the reporter's screenshot.
//
// The fake upstream below is Zylo-faithful: /v1/models is open, /v1/chat/completions is the
// only authenticated route.
let server: Server;
let baseUrl = "";
let modelsRequests = 0;
let chatRequests = 0;
let lastChatAuthorization: string | null = null;
const VALID_KEY = "zk-valid-test-key";
before(async () => {
server = createServer((req, res) => {
const path = (req.url || "").split("?")[0];
const auth = req.headers["authorization"];
if (path === "/v1/models") {
// Open catalog: no Authorization required, any key accepted.
modelsRequests += 1;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ text: ["gpt-oss"], image: [], submodels: [] }));
return;
}
if (path === "/v1/chat/completions") {
chatRequests += 1;
lastChatAuthorization = typeof auth === "string" ? auth : null;
if (auth !== `Bearer ${VALID_KEY}`) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Key not found: zk-i…" }));
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }));
return;
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not Found");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("no assigned port");
baseUrl = `http://127.0.0.1:${address.port}`;
});
after(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
});
test("#13828 zylo key validation rejects a bad key instead of trusting the open catalog", async () => {
chatRequests = 0;
modelsRequests = 0;
const result = await validateProviderApiKey({
provider: "zylo-api",
apiKey: "zk-invalid-test-key",
providerSpecificData: { baseUrl },
});
assert.equal(
modelsRequests,
0,
"the open catalog must not be consulted — a 200 from it is what greened bad keys"
);
assert.equal(
result.valid,
false,
"a key Zylo rejects on the chat route must not validate just because /v1/models is open"
);
assert.ok(chatRequests > 0, "validation must probe the authenticated chat route");
assert.equal(lastChatAuthorization, "Bearer zk-invalid-test-key");
});
test("#13828 zylo key validation still accepts a key the chat route authenticates", async () => {
const result = await validateProviderApiKey({
provider: "zylo-api",
apiKey: VALID_KEY,
providerSpecificData: { baseUrl },
});
assert.equal(result.valid, true, `expected a valid verdict, got ${JSON.stringify(result)}`);
});
test("#13828 the `zylo` alias validates through the same authenticated probe", async () => {
const result = await validateProviderApiKey({
provider: "zylo",
apiKey: "zk-invalid-test-key",
providerSpecificData: { baseUrl },
});
assert.equal(result.valid, false, "the alias must not fall back to the open-catalog probe");
});