mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
Compare commits
1 Commits
fix/11002-
...
fix/10986-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9493a53edd |
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
|
||||||
@@ -1 +0,0 @@
|
|||||||
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
|
|
||||||
@@ -455,9 +455,10 @@
|
|||||||
"src/sse/handlers/chatHelpers.ts": 1019,
|
"src/sse/handlers/chatHelpers.ts": 1019,
|
||||||
"src/shared/middleware/chatBodyAdmission.ts": 1005,
|
"src/shared/middleware/chatBodyAdmission.ts": 1005,
|
||||||
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
|
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
|
||||||
"open-sse/executors/commandCode.ts": 1038,
|
"open-sse/executors/commandCode.ts": 1059,
|
||||||
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
|
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
|
||||||
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts."
|
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
|
||||||
|
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)."
|
||||||
},
|
},
|
||||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ export const difyProvider: RegistryEntry = {
|
|||||||
alias: "dify",
|
alias: "dify",
|
||||||
format: "openai",
|
format: "openai",
|
||||||
executor: "default",
|
executor: "default",
|
||||||
// Dify does not serve /chat/completions — its native completion route is
|
baseUrl: "https://api.dify.ai/v1/chat/completions",
|
||||||
// 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",
|
authType: "apikey",
|
||||||
authHeader: "bearer",
|
authHeader: "bearer",
|
||||||
models: [{ id: "auto", name: "Auto" }],
|
models: [{ id: "auto", name: "Auto" }],
|
||||||
|
|||||||
@@ -738,6 +738,7 @@ function createStreamResponse(
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let sentRole = false;
|
let sentRole = false;
|
||||||
|
let sentContent = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const state: AggregateState = {
|
const state: AggregateState = {
|
||||||
content: "",
|
content: "",
|
||||||
@@ -772,7 +773,10 @@ function createStreamResponse(
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text-delta": {
|
case "text-delta": {
|
||||||
const text = stringValue(event.text) || "";
|
const text = stringValue(event.text) || "";
|
||||||
if (text) controller.enqueue(sse(chatCompletionChunk(id, model, { content: text })));
|
if (text) {
|
||||||
|
sentContent = true;
|
||||||
|
controller.enqueue(sse(chatCompletionChunk(id, model, { content: text })));
|
||||||
|
}
|
||||||
state.content += text;
|
state.content += text;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -810,6 +814,12 @@ function createStreamResponse(
|
|||||||
break;
|
break;
|
||||||
case "finish": {
|
case "finish": {
|
||||||
state.finishReason = mapFinishReason(event.finishReason);
|
state.finishReason = mapFinishReason(event.finishReason);
|
||||||
|
// If the model only produced reasoning-delta events (no text-delta), the
|
||||||
|
// client-visible stream would otherwise end with no content. Emit one
|
||||||
|
// content delta carrying the accumulated reasoning text (#10986).
|
||||||
|
if (!sentContent && state.reasoning && state.toolCalls.length === 0) {
|
||||||
|
controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning })));
|
||||||
|
}
|
||||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||||
// Emit a standards-compliant usage-only chunk (choices: []) before
|
// Emit a standards-compliant usage-only chunk (choices: []) before
|
||||||
// [DONE] when upstream reported usage. stream.ts's extractUsage
|
// [DONE] when upstream reported usage. stream.ts's extractUsage
|
||||||
@@ -857,6 +867,9 @@ function createStreamResponse(
|
|||||||
if (!closed) {
|
if (!closed) {
|
||||||
if (!sentRole)
|
if (!sentRole)
|
||||||
controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
|
controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
|
||||||
|
if (!sentContent && state.reasoning && state.toolCalls.length === 0) {
|
||||||
|
controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning })));
|
||||||
|
}
|
||||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
controller.close();
|
controller.close();
|
||||||
@@ -947,6 +960,14 @@ async function createJsonResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message: JsonRecord = { role: "assistant", content: state.content };
|
const message: JsonRecord = { role: "assistant", content: state.content };
|
||||||
|
// Some Command Code models emit the whole answer as reasoning-delta events and
|
||||||
|
// never a text-delta. When that leaves content empty, surface the reasoning text
|
||||||
|
// as content too (#10986) so OpenAI-compatible clients get a usable answer. Keep
|
||||||
|
// reasoning_content populated as well for reasoning-aware clients, and do not
|
||||||
|
// override content when real text OR tool calls are present.
|
||||||
|
if (!state.content && state.reasoning && state.toolCalls.length === 0) {
|
||||||
|
message.content = state.reasoning;
|
||||||
|
}
|
||||||
if (state.reasoning) message.reasoning_content = state.reasoning;
|
if (state.reasoning) message.reasoning_content = state.reasoning;
|
||||||
if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls;
|
if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls;
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ import {
|
|||||||
validateBytezProvider,
|
validateBytezProvider,
|
||||||
} from "./validation/webCookie";
|
} from "./validation/webCookie";
|
||||||
import { validateAiHordeProvider } from "./validation/aihorde";
|
import { validateAiHordeProvider } from "./validation/aihorde";
|
||||||
import { validateDifyProvider } from "./validation/dify";
|
|
||||||
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
|
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
|
||||||
import {
|
import {
|
||||||
validateV0VercelProvider,
|
validateV0VercelProvider,
|
||||||
@@ -231,10 +230,6 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
|||||||
freebuff: validateFreebuffProvider,
|
freebuff: validateFreebuffProvider,
|
||||||
"command-code": validateCommandCodeProvider,
|
"command-code": validateCommandCodeProvider,
|
||||||
huggingface: validateHuggingFaceProvider,
|
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
|
// #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.
|
// its catalog, so the generic chat probe can't validate a fresh key.
|
||||||
bytez: validateBytezProvider,
|
bytez: validateBytezProvider,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -333,6 +333,68 @@ test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Command Code reasoning-only output falls back to reasoning as content (non-stream)", async () => {
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
commandCodeStream(
|
||||||
|
[
|
||||||
|
{ type: "reasoning-delta", text: "The user wants 79874+93658. " },
|
||||||
|
{ type: "reasoning-delta", text: "That equals 173532." },
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
totalUsage: { inputTokens: 20, outputTokens: 64, outputTokenDetails: { reasoningTokens: 61 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ sse: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { response } = await getExecutor("command-code").execute({
|
||||||
|
model: "meta/muse-spark-1.2-contributor",
|
||||||
|
stream: false,
|
||||||
|
credentials: { apiKey: "cc_test_key" },
|
||||||
|
body: { messages: [{ role: "user", content: "Calculate 79874+93658, and reply with the result only." }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const message = json.choices[0].message;
|
||||||
|
// Regression #10986: when the model emits only reasoning-delta events (never a
|
||||||
|
// text-delta), content must fall back to the reasoning text instead of "" (which
|
||||||
|
// OpenAI-compatible clients treat as null/no answer).
|
||||||
|
assert.equal(message.content, "The user wants 79874+93658. That equals 173532.");
|
||||||
|
// reasoning_content must STAY populated for reasoning-aware clients.
|
||||||
|
assert.equal(message.reasoning_content, "The user wants 79874+93658. That equals 173532.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Command Code reasoning-only output emits a content delta chunk when streaming", async () => {
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
commandCodeStream(
|
||||||
|
[
|
||||||
|
{ type: "reasoning-delta", text: "The result is 173532." },
|
||||||
|
{ type: "finish", finishReason: "stop" },
|
||||||
|
],
|
||||||
|
{ sse: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { response } = await getExecutor("command-code").execute({
|
||||||
|
model: "meta/muse-spark-1.2-contributor",
|
||||||
|
stream: true,
|
||||||
|
credentials: { apiKey: "cc_test_key" },
|
||||||
|
body: { messages: [{ role: "user", content: "Calcular 79874+93658" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sse = await response.text();
|
||||||
|
assert.match(sse, /data: \[DONE\]/);
|
||||||
|
const chunks = parseSsePayloads(sse);
|
||||||
|
assert.equal(chunks[0].choices[0].delta.role, "assistant");
|
||||||
|
// Regression #10986: the reasoning-only stream must emit a content delta when it
|
||||||
|
// otherwise ends with no content. reasoning_content stays present too.
|
||||||
|
const contentDelta = chunks.find((c) => c.choices[0].delta.content !== undefined);
|
||||||
|
assert.equal(contentDelta.choices[0].delta.content, "The result is 173532.");
|
||||||
|
const reasoningDelta = chunks.find((c) => c.choices[0].delta.reasoning_content !== undefined);
|
||||||
|
assert.equal(reasoningDelta.choices[0].delta.reasoning_content, "The result is 173532.");
|
||||||
|
assert.equal(chunks.at(-1).choices[0].finish_reason, "stop");
|
||||||
|
});
|
||||||
|
|
||||||
test("Command Code executor surfaces upstream and streamed errors", async () => {
|
test("Command Code executor surfaces upstream and streamed errors", async () => {
|
||||||
globalThis.fetch = async () =>
|
globalThis.fetch = async () =>
|
||||||
new Response("bad key", { status: 401, statusText: "Unauthorized" });
|
new Response("bad key", { status: 401, statusText: "Unauthorized" });
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user