Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge

This commit is contained in:
Stephen Zhou 2026-07-29 20:27:52 +08:00
commit addd754eb3
No known key found for this signature in database
6 changed files with 96 additions and 34 deletions

View File

@ -132,8 +132,8 @@ describe("createApiVisualEmbeddingOptions", () => {
}),
).resolves.toMatchObject({
dense: [[0.2, 0.8]],
metadata: { model: "clip-multimodal@1", provider: "dify-model-runtime" },
model: "clip-multimodal@1",
metadata: { model: "clip-multimodal", provider: "dify-model-runtime" },
model: "clip-multimodal",
});
const queryPayload = (await requests[1]?.json()) as Record<string, unknown>;

View File

@ -29,7 +29,7 @@ const BASE = {
} as const;
describe("Dify model runtime embedding provider", () => {
it("embeds through Dify and maps the ModelInstance response", async () => {
it("embeds through Dify and preserves the selected route across upstream model aliases", async () => {
const calls: DifyTextEmbeddingInput[] = [];
const provider = createDifyModelRuntimeEmbeddingProvider({
...BASE,
@ -61,11 +61,11 @@ describe("Dify model runtime embedding provider", () => {
],
metadata: {
dimension: 2,
model: "resolved-model",
model: "text-embedding-3-large",
provider: "dify-model-runtime",
usage: { totalTokens: 7 },
},
model: "resolved-model",
model: "text-embedding-3-large",
});
expect(calls[0]).toMatchObject({
inputType: "document",
@ -109,6 +109,17 @@ describe("Dify model runtime embedding provider", () => {
);
});
it("rejects a model outside the provider's bound Dify route", async () => {
const provider = createDifyModelRuntimeEmbeddingProvider({
...BASE,
client: fakeClient(async () => ({ embeddings: [[1, 1]] })),
});
await expect(
provider.embed({ model: "other-embedding", tenantId: "t", texts: ["q"] }),
).rejects.toThrow("is bound to model text-embedding-3-large");
});
it("rejects invalid or mismatched embedding responses", async () => {
const invalid = createDifyModelRuntimeEmbeddingProvider({
...BASE,

View File

@ -87,6 +87,22 @@ describe("Dify model runtime reranker provider", () => {
).rejects.toBeInstanceOf(ProviderInputError);
});
it("rejects a model outside the provider's bound Dify route", async () => {
const provider = createDifyModelRuntimeRerankerProvider({
...BASE,
client: fakeClient(async () => ({ docs: [] })),
});
await expect(
provider.rerank({
documents: DOCS,
model: "other-reranker",
query: "q",
tenantId: "t",
}),
).rejects.toThrow("is bound to model rerank-english-v3.0");
});
it("fails closed for out-of-range indices and invalid responses", async () => {
const outOfRange = createDifyModelRuntimeRerankerProvider({
...BASE,
@ -121,10 +137,6 @@ describe("Dify model runtime reranker provider", () => {
},
label: "duplicate indices",
},
{
data: { docs: [{ index: 0, score: 0.9 }], model: "different-model" },
label: "a mismatched model identity",
},
])("rejects $label", async ({ data }) => {
const provider = createDifyModelRuntimeRerankerProvider({
...BASE,
@ -136,6 +148,23 @@ describe("Dify model runtime reranker provider", () => {
).rejects.toBeInstanceOf(ProviderResponseError);
});
it("preserves the selected route when Dify reports an upstream model alias", async () => {
const provider = createDifyModelRuntimeRerankerProvider({
...BASE,
client: fakeClient(async () => ({
docs: [{ index: 0, score: 0.9 }],
model: "rerank-english-v3.0-runtime-deployment",
})),
});
await expect(
provider.rerank({ documents: DOCS, model: BASE.model, query: "q", tenantId: "t" }),
).resolves.toMatchObject({
metadata: { model: BASE.model },
model: BASE.model,
});
});
it("synthesizes a model descriptor and validates constructor options", async () => {
const provider = createDifyModelRuntimeRerankerProvider({
...BASE,

View File

@ -464,6 +464,7 @@ export function createDifyModelRuntimeEmbeddingProvider(
kind: "dify-model-runtime",
async embed(input) {
validateEmbedInput(input, { maxBatchSize, maxTextBytes });
assertDifyRouteModel(input.model, options.model, "embedding");
const tenantId = input.tenantId?.trim();
@ -495,10 +496,8 @@ export function createDifyModelRuntimeEmbeddingProvider(
);
}
const model = parsed.data.model ?? input.model;
const dimension = validateEmbeddingResponseVectors(parsed.data.embeddings);
const configuredDimension =
observedDimensions.get(input.model) ?? observedDimensions.get(model);
const configuredDimension = observedDimensions.get(input.model);
if (configuredDimension !== undefined && configuredDimension !== dimension) {
throw new ProviderResponseError(
@ -507,18 +506,20 @@ export function createDifyModelRuntimeEmbeddingProvider(
}
observedDimensions.set(input.model, dimension);
observedDimensions.set(model, dimension);
const totalTokens = parsed.data.usage?.total_tokens ?? parsed.data.usage?.tokens;
return {
dense: parsed.data.embeddings.map((vector) => [...vector]),
metadata: {
dimension,
model,
// Dify has already resolved and invoked the exact catalog route selected by the
// knowledge-space profile. Upstream providers may report a deployment name or
// versioned alias, so expose the stable Dify route identity to downstream checks.
model: input.model,
provider: "dify-model-runtime",
...(totalTokens === undefined ? {} : { usage: { totalTokens } }),
},
model,
model: input.model,
};
},
async models() {
@ -624,6 +625,7 @@ export function createDifyModelRuntimeRerankerProvider(
kind: "dify-model-runtime",
async rerank(input) {
validateRerankInput(input, { maxDocuments, maxTextBytes });
assertDifyRouteModel(input.model, options.model, "rerank");
const tenantId = input.tenantId?.trim();
@ -680,17 +682,12 @@ export function createDifyModelRuntimeRerankerProvider(
};
});
const model = (parsed.data.model ?? input.model).trim();
if (!model || model !== input.model) {
throw new ProviderResponseError(
`Dify rerank model mismatch: requested=${input.model}, returned=${parsed.data.model ?? ""}`,
);
}
return {
items,
metadata: { model, provider: "dify-model-runtime" },
model,
// The response model may be an upstream deployment name or versioned alias.
// Catalog resolution plus the tenant-bound Dify invocation establish the route identity.
metadata: { model: input.model, provider: "dify-model-runtime" },
model: input.model,
};
},
async models() {
@ -711,6 +708,18 @@ function defaultDifyModelRuntimeRerankerModel(
};
}
function assertDifyRouteModel(
requestedModel: string,
configuredModel: string,
capability: "embedding" | "rerank",
): void {
if (requestedModel.trim() !== configuredModel.trim()) {
throw new ProviderInputError(
`Dify model runtime ${capability} provider is bound to model ${configuredModel.trim()}`,
);
}
}
function validateEmbedInput(
input: EmbedTextsInput,
limits: { readonly maxBatchSize: number; readonly maxTextBytes: number },

View File

@ -36,15 +36,18 @@ function deltaChunk(content: string): unknown {
}
describe("Dify model runtime LLM provider", () => {
it("streams delta events then a done event with usage, and maps the request", async () => {
it("streams deltas and preserves the selected route across upstream model aliases", async () => {
let captured: DifyLlmInput | undefined;
const provider = createDifyModelRuntimeLlmProvider({
...BASE,
client: fakeClient(
() => [
{ delta: { message: { content: "Hel" } }, model: "gpt-4.1-mini-2025" },
{ delta: { message: { content: "Hel" } }, model: BASE.model },
deltaChunk("lo"),
{ delta: { finish_reason: "stop", usage: { completion_tokens: 2, prompt_tokens: 5 } } },
{
delta: { finish_reason: "stop", usage: { completion_tokens: 2, prompt_tokens: 5 } },
model: "gpt-4.1-mini-runtime-deployment",
},
],
(input) => {
captured = input;
@ -73,7 +76,7 @@ describe("Dify model runtime LLM provider", () => {
{
finishReason: "stop",
metadata: {
model: "gpt-4.1-mini-2025",
model: BASE.model,
provider: "dify-model-runtime",
usage: { completionTokens: 2, promptTokens: 5 },
},
@ -186,6 +189,13 @@ describe("Dify model runtime LLM provider", () => {
await expect(
provider.generate({ messages: [{ content: "hi", role: "user" }], model: "gpt-4.1-mini" }),
).rejects.toThrow("requires a tenantId");
await expect(
provider.generate({
messages: [{ content: "hi", role: "user" }],
model: "other-llm",
tenantId: "t",
}),
).rejects.toThrow("is bound to model gpt-4.1-mini");
});
it("threads model parameters, skips unparseable chunks, and maps partial usage", async () => {

View File

@ -778,6 +778,11 @@ export function createDifyModelRuntimeLlmProvider(
if (!input.model.trim()) {
throw new ProviderInputError("Dify model runtime LLM model is required");
}
if (input.model.trim() !== options.model.trim()) {
throw new ProviderInputError(
`Dify model runtime LLM provider is bound to model ${options.model.trim()}`,
);
}
const tenantId = input.tenantId?.trim();
@ -786,7 +791,6 @@ export function createDifyModelRuntimeLlmProvider(
}
const maxTokens = input.maxOutputTokens ?? options.maxOutputTokens;
let model = input.model;
let finishReason = "stop";
let usage: LlmUsage | undefined;
@ -811,10 +815,6 @@ export function createDifyModelRuntimeLlmProvider(
continue;
}
if (parsed.data.model) {
model = parsed.data.model;
}
const content = parsed.data.delta?.message?.content;
if (content) {
@ -844,7 +844,10 @@ export function createDifyModelRuntimeLlmProvider(
yield {
finishReason,
metadata: { model, provider: "dify-model-runtime", ...(usage ? { usage } : {}) },
// Dify invokes the exact tenant-scoped catalog route requested above. Individual
// stream chunks can expose different upstream aliases (for example a terminal chunk
// may report a routed deployment name), so keep the logical Dify route deterministic.
metadata: { model: input.model, provider: "dify-model-runtime", ...(usage ? { usage } : {}) },
type: "done",
};
}