mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 23:22:09 +03:00
Compare commits
1 Commits
fix/11095-
...
fix/11002-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a1db9cdb |
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
|
||||
@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
|
||||
alias: "dify",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.dify.ai/v1/chat/completions",
|
||||
// Dify does not serve /chat/completions — its native completion route is
|
||||
// POST /v1/chat-messages (validated via the dedicated dify validator, #11002).
|
||||
// Keep this as the bare API root so route suffixes build correctly and
|
||||
// self-hosted instances can override the base URL per connection.
|
||||
baseUrl: "https://api.dify.ai",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "auto", name: "Auto" }],
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
validateBytezProvider,
|
||||
} from "./validation/webCookie";
|
||||
import { validateAiHordeProvider } from "./validation/aihorde";
|
||||
import { validateDifyProvider } from "./validation/dify";
|
||||
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
|
||||
import {
|
||||
validateV0VercelProvider,
|
||||
@@ -230,6 +231,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
freebuff: validateFreebuffProvider,
|
||||
"command-code": validateCommandCodeProvider,
|
||||
huggingface: validateHuggingFaceProvider,
|
||||
// #11002: Dify serves no OpenAI-compatible route — only POST /v1/chat-messages.
|
||||
// The generic OpenAI-like probe 404s on /v1/models and /v1/chat/completions,
|
||||
// so every real app key was misreported as "endpoint not supported".
|
||||
dify: validateDifyProvider,
|
||||
// #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,
|
||||
|
||||
86
src/lib/providers/validation/dify.ts
Normal file
86
src/lib/providers/validation/dify.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Dify key check. Dify (multi-app LLM "LLMOps" platform) does NOT expose an
|
||||
* OpenAI-compatible HTTP API. Its native completion endpoint is
|
||||
* `POST {base}/v1/chat-messages` (body `inputs`/`query`/`response_mode`/`user`
|
||||
* — no `model`/`messages` envelope). There is no `/v1/models` listing, so the
|
||||
* generic OpenAI-like probe (GET /v1/models → POST /v1/chat/completions)
|
||||
* always 404s and every real Dify app key is misreported as
|
||||
* "Provider validation endpoint not supported" (#11002).
|
||||
*
|
||||
* Dify itself returns a clean 401 {"code":"unauthorized"} for a bad app key on
|
||||
* `/v1/chat-messages`, and 200 for a valid key, so a single POST there is the
|
||||
* correct auth probe.
|
||||
*/
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { normalizeBaseUrl } from "./urlHelpers";
|
||||
import { toValidationErrorResult, validationWrite } from "./transport";
|
||||
|
||||
/**
|
||||
* Shape a provider/connection base URL into the Dify native completion route.
|
||||
* Accepts the cloud root (`https://api.dify.ai`), a `/v1` root, or a full
|
||||
* `/v1/chat-messages` URL (e.g. a self-hosted instance) and always returns
|
||||
* `{base}/v1/chat-messages`.
|
||||
*/
|
||||
export function resolveDifyChatMessagesUrl(baseUrl: string) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return "";
|
||||
const cleaned = normalized.replace(/\/chat-messages$/, "").replace(/\/v1$/, "");
|
||||
return `${cleaned}/v1/chat-messages`;
|
||||
}
|
||||
|
||||
/** Pure status→verdict mapping, unit-testable without network. */
|
||||
export function difyValidationResultFromStatus(status: number) {
|
||||
if (status === 401 || status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
if (status >= 200 && status < 300) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
return { valid: false, error: `Dify validation failed (${status})` };
|
||||
}
|
||||
|
||||
export async function validateDifyProvider({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
fetchImpl = validationWrite,
|
||||
}: {
|
||||
apiKey?: unknown;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
fetchImpl?: typeof validationWrite;
|
||||
}) {
|
||||
const key = typeof apiKey === "string" ? apiKey.trim() : "";
|
||||
if (!key) {
|
||||
return { valid: false, error: "API key required" };
|
||||
}
|
||||
|
||||
const specificBase =
|
||||
typeof providerSpecificData?.baseUrl === "string" ? providerSpecificData.baseUrl.trim() : "";
|
||||
const entryBase = (getRegistryEntry("dify")?.baseUrl as string) || "";
|
||||
const probeUrl = resolveDifyChatMessagesUrl(specificBase || entryBase);
|
||||
if (!probeUrl) {
|
||||
return { valid: false, error: "Dify requires a Base URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
probeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: {},
|
||||
query: "ping",
|
||||
response_mode: "blocking",
|
||||
user: "omniroute-key-check",
|
||||
}),
|
||||
},
|
||||
false
|
||||
);
|
||||
return difyValidationResultFromStatus(response.status);
|
||||
} catch (error) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
100
tests/unit/dify-key-validation-repro.test.ts
Normal file
100
tests/unit/dify-key-validation-repro.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { readFileSync } from "node:fs";
|
||||
import { validateProviderApiKey } from "../../src/lib/providers/validation.ts";
|
||||
import {
|
||||
difyValidationResultFromStatus,
|
||||
resolveDifyChatMessagesUrl,
|
||||
} from "../../src/lib/providers/validation/dify.ts";
|
||||
import { difyProvider } from "../../open-sse/config/providers/registry/dify/index.ts";
|
||||
|
||||
// #11002 — the `dify` provider is registered with format:"openai", so the generic OpenAI-like
|
||||
// validation probe hits GET /v1/models then POST /v1/chat/completions. Dify's native API serves
|
||||
// neither — it only exposes POST /v1/chat-messages (401 {"code":"unauthorized"} for a bad key).
|
||||
// Every real Dify app key therefore fails validation with the generic
|
||||
// "Provider validation endpoint not supported" instead of a clean invalid/valid verdict.
|
||||
//
|
||||
// The fake upstream below is Dify-faithful: /v1/models and /v1/chat/completions 404, while
|
||||
// /v1/chat-messages is the only route and answers 401 for a bad key.
|
||||
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
before(async () => {
|
||||
server = createServer((req, res) => {
|
||||
const path = (req.url || "").split("?")[0];
|
||||
if (path === "/v1/models") {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("Not Found");
|
||||
} else if (path === "/v1/chat/completions") {
|
||||
res.writeHead(404, { "content-type": "text/html" });
|
||||
res.end("<html>404 Not Found</html>");
|
||||
} else if (path === "/v1/chat-messages") {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ code: "unauthorized", message: "Access token is invalid" }));
|
||||
} else {
|
||||
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("#11002 dify key validation probes /v1/chat-messages and rejects a bad key", async () => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "dify",
|
||||
apiKey: "app-test-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("#11002 dify status→result maps bad keys and valid keys", () => {
|
||||
assert.deepEqual(difyValidationResultFromStatus(401), {
|
||||
valid: false,
|
||||
error: "Invalid API key",
|
||||
});
|
||||
assert.deepEqual(difyValidationResultFromStatus(403), {
|
||||
valid: false,
|
||||
error: "Invalid API key",
|
||||
});
|
||||
assert.deepEqual(difyValidationResultFromStatus(200), { valid: true, error: null });
|
||||
assert.deepEqual(difyValidationResultFromStatus(500), {
|
||||
valid: false,
|
||||
error: "Dify validation failed (500)",
|
||||
});
|
||||
});
|
||||
|
||||
test("#11002 resolveDifyChatMessagesUrl always targets /v1/chat-messages", () => {
|
||||
assert.equal(resolveDifyChatMessagesUrl("https://api.dify.ai"), "https://api.dify.ai/v1/chat-messages");
|
||||
assert.equal(
|
||||
resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1"),
|
||||
"https://selfhosted.example.com/v1/chat-messages"
|
||||
);
|
||||
assert.equal(
|
||||
resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1/chat-messages"),
|
||||
"https://selfhosted.example.com/v1/chat-messages"
|
||||
);
|
||||
});
|
||||
|
||||
test("#11002 dify registry baseUrl is the bare API root, not /chat/completions", () => {
|
||||
assert.equal(difyProvider.baseUrl, "https://api.dify.ai");
|
||||
const src = readFileSync(
|
||||
new URL("../../src/lib/providers/validation.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(src, /dify:\s*validateDifyProvider/);
|
||||
});
|
||||
Reference in New Issue
Block a user