From ba274b616af5db23982df39f7145d1bda2e51237 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 16 Sep 2026 13:35:33 -0300 Subject: [PATCH] fix(providers): validate Zylo keys against the chat route, not its open catalog (#13877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .../fixes/13828-zylo-key-validation.md | 1 + src/lib/providers/validation.ts | 11 ++ src/lib/providers/validation/zylo.ts | 52 +++++++++ tests/unit/zylo-key-validation-13828.test.ts | 109 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 changelog.d/fixes/13828-zylo-key-validation.md create mode 100644 src/lib/providers/validation/zylo.ts create mode 100644 tests/unit/zylo-key-validation-13828.test.ts diff --git a/changelog.d/fixes/13828-zylo-key-validation.md b/changelog.d/fixes/13828-zylo-key-validation.md new file mode 100644 index 0000000000..2d339616cb --- /dev/null +++ b/changelog.d/fixes/13828-zylo-key-validation.md @@ -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)) diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 3f52b406cd..d0dd5d3d17 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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/ 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, diff --git a/src/lib/providers/validation/zylo.ts b/src/lib/providers/validation/zylo.ts new file mode 100644 index 0000000000..55cf3c74a0 --- /dev/null +++ b/src/lib/providers/validation/zylo.ts @@ -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, + }); +} diff --git a/tests/unit/zylo-key-validation-13828.test.ts b/tests/unit/zylo-key-validation-13828.test.ts new file mode 100644 index 0000000000..cd19adb037 --- /dev/null +++ b/tests/unit/zylo-key-validation-13828.test.ts @@ -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((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((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"); +});