Compare commits

..

1 Commits

10 changed files with 49 additions and 217 deletions

View File

@@ -1 +0,0 @@
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)

View File

@@ -0,0 +1 @@
- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)

View File

@@ -5,11 +5,7 @@ export const difyProvider: RegistryEntry = {
alias: "dify",
format: "openai",
executor: "default",
// 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",
baseUrl: "https://api.dify.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }],

View File

@@ -108,7 +108,6 @@ import {
validateBytezProvider,
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
import { validateDifyProvider } from "./validation/dify";
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
import {
validateV0VercelProvider,
@@ -231,10 +230,6 @@ 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,

View File

@@ -1,86 +0,0 @@
/**
* 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);
}
}

View File

@@ -639,20 +639,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
text: "Dahl auto-generates tokens via https://inference.dahl.global/tokens. No signup needed. Rate limits apply. You can also add your own API key.",
},
},
uncloseai: {
id: "uncloseai",
alias: "unc",
name: "UncloseAI",
icon: "auto_awesome",
color: "#8B5CF6",
textIcon: "UN",
website: "https://uncloseai.com",
hasFree: true,
freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
passthroughModels: true,
authHint:
"No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
},
hackclub: {
id: "hackclub",
alias: "hc",

View File

@@ -175,6 +175,25 @@ export const NOAUTH_PROVIDERS = {
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
},
},
uncloseai: {
id: "uncloseai",
alias: "unc",
name: "UncloseAI",
icon: "auto_awesome",
color: "#8B5CF6",
textIcon: "UN",
website: "https://uncloseai.com",
noAuth: true,
hasFree: true,
passthroughModels: true,
serviceKinds: ["llm"],
authHint:
"No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
notice: {
text: "UncloseAI needs no API key. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync.",
},
},
aihorde: {
id: "aihorde",
alias: "horde",

View File

@@ -1,100 +0,0 @@
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/);
});

View File

@@ -24,7 +24,8 @@
// (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media,
// #10729) brings it to 229; Token Kiosk (gateways, #10722) — merged in the same
// merge-train batch — independently bumped the gateways family too, landing at 231; Freebuff
// (gateways, #10531) brings it to 232.
// (gateways, #10531) brings it to 232. #8864 moves uncloseai (gateways family) into
// NOAUTH_PROVIDERS, dropping the APIKEY_PROVIDERS count to 231.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -53,12 +54,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 232);
assert.equal(new Set(keys).size, 232, "duplicate keys after spread-merge");
assert.equal(keys.length, 231);
assert.equal(new Set(keys).size, 231, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 232.
// strict partition (every provider in exactly one), so the sum must be exactly 231.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -78,7 +79,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 232, "families must partition all 232 providers");
assert.equal(famTotal, 231, "families must partition all 231 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {

View File

@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { NOAUTH_PROVIDERS, providerAllowsOptionalApiKey } from "@/shared/constants/providers";
test("uncloseai should be treated as a no-auth provider", () => {
const isNoAuthRegistered = Object.prototype.hasOwnProperty.call(NOAUTH_PROVIDERS, "uncloseai");
const allowsOptionalKey = providerAllowsOptionalApiKey("uncloseai");
assert.equal(
isNoAuthRegistered || allowsOptionalKey,
true,
"uncloseai must be registered in NOAUTH_PROVIDERS or allow an optional API key " +
"so the dashboard doesn't force users to enter a key for a no-auth provider"
);
assert.equal(
isNoAuthRegistered,
true,
"uncloseai must be registered in NOAUTH_PROVIDERS (not just allow an optional key) " +
"so the dashboard renders the NoAuthProviderControls flow"
);
});