mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 04:12:17 +03:00
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:
committed by
GitHub
parent
309b635740
commit
ba274b616a
1
changelog.d/fixes/13828-zylo-key-validation.md
Normal file
1
changelog.d/fixes/13828-zylo-key-validation.md
Normal 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))
|
||||
@@ -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,
|
||||
|
||||
52
src/lib/providers/validation/zylo.ts
Normal file
52
src/lib/providers/validation/zylo.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
109
tests/unit/zylo-key-validation-13828.test.ts
Normal file
109
tests/unit/zylo-key-validation-13828.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user