diff --git a/knowledge-fs/apps/api/src/visual-embedding-options.test.ts b/knowledge-fs/apps/api/src/visual-embedding-options.test.ts index ca9257974d2..7e8651c0ab7 100644 --- a/knowledge-fs/apps/api/src/visual-embedding-options.test.ts +++ b/knowledge-fs/apps/api/src/visual-embedding-options.test.ts @@ -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; diff --git a/knowledge-fs/packages/embeddings/src/dify-model-runtime-embedding.test.ts b/knowledge-fs/packages/embeddings/src/dify-model-runtime-embedding.test.ts index 1ef79d3cb18..3aed4c6f182 100644 --- a/knowledge-fs/packages/embeddings/src/dify-model-runtime-embedding.test.ts +++ b/knowledge-fs/packages/embeddings/src/dify-model-runtime-embedding.test.ts @@ -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, diff --git a/knowledge-fs/packages/embeddings/src/dify-model-runtime-rerank.test.ts b/knowledge-fs/packages/embeddings/src/dify-model-runtime-rerank.test.ts index 2e488db2081..b81673d82c6 100644 --- a/knowledge-fs/packages/embeddings/src/dify-model-runtime-rerank.test.ts +++ b/knowledge-fs/packages/embeddings/src/dify-model-runtime-rerank.test.ts @@ -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, diff --git a/knowledge-fs/packages/embeddings/src/index.ts b/knowledge-fs/packages/embeddings/src/index.ts index dd86a25c15a..80a93d620cd 100644 --- a/knowledge-fs/packages/embeddings/src/index.ts +++ b/knowledge-fs/packages/embeddings/src/index.ts @@ -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 }, diff --git a/knowledge-fs/packages/generation/src/dify-model-runtime-llm.test.ts b/knowledge-fs/packages/generation/src/dify-model-runtime-llm.test.ts index c512f9e0a38..644f55f62d4 100644 --- a/knowledge-fs/packages/generation/src/dify-model-runtime-llm.test.ts +++ b/knowledge-fs/packages/generation/src/dify-model-runtime-llm.test.ts @@ -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 () => { diff --git a/knowledge-fs/packages/generation/src/index.ts b/knowledge-fs/packages/generation/src/index.ts index 1e6a1e142df..2a66c16df1a 100644 --- a/knowledge-fs/packages/generation/src/index.ts +++ b/knowledge-fs/packages/generation/src/index.ts @@ -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", }; }