From 97a1db9cdb0c362182923a64fcdf0b7098a47d3b Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Fri, 21 Aug 2026 19:52:13 -0300 Subject: [PATCH] fix(providers): validate Dify keys against native /v1/chat-messages endpoint (#11002) --- .../fixes/11002-dify-key-validation.md | 1 + .../config/providers/registry/dify/index.ts | 6 +- src/lib/providers/validation.ts | 5 + src/lib/providers/validation/dify.ts | 86 +++++++++++++++ tests/unit/dify-key-validation-repro.test.ts | 100 ++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/11002-dify-key-validation.md create mode 100644 src/lib/providers/validation/dify.ts create mode 100644 tests/unit/dify-key-validation-repro.test.ts diff --git a/changelog.d/fixes/11002-dify-key-validation.md b/changelog.d/fixes/11002-dify-key-validation.md new file mode 100644 index 0000000000..6574714c9b --- /dev/null +++ b/changelog.d/fixes/11002-dify-key-validation.md @@ -0,0 +1 @@ +- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002) \ No newline at end of file diff --git a/open-sse/config/providers/registry/dify/index.ts b/open-sse/config/providers/registry/dify/index.ts index de1d5b03cc..80c9c7690f 100644 --- a/open-sse/config/providers/registry/dify/index.ts +++ b/open-sse/config/providers/registry/dify/index.ts @@ -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" }], diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 7f8180dee0..8148173d1a 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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, diff --git a/src/lib/providers/validation/dify.ts b/src/lib/providers/validation/dify.ts new file mode 100644 index 0000000000..3834af0e93 --- /dev/null +++ b/src/lib/providers/validation/dify.ts @@ -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; + 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); + } +} \ No newline at end of file diff --git a/tests/unit/dify-key-validation-repro.test.ts b/tests/unit/dify-key-validation-repro.test.ts new file mode 100644 index 0000000000..0bbf430379 --- /dev/null +++ b/tests/unit/dify-key-validation-repro.test.ts @@ -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("404 Not Found"); + } 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((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("#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/); +}); \ No newline at end of file