diff --git a/packages/@n8n/agents/src/runtime/__tests__/model-factory-stream.test.ts b/packages/@n8n/agents/src/runtime/__tests__/model-factory-stream.test.ts new file mode 100644 index 00000000000..fcd1fbd52e6 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/model-factory-stream.test.ts @@ -0,0 +1,70 @@ +import { streamText } from 'ai'; + +import { createModel } from '../model/model-factory'; + +describe('createModel streaming', () => { + it('streams array-based reasoning and text from a custom endpoint', async () => { + const deltas = [ + ...['Let me ', 'think.'].map((text) => ({ + content: [{ type: 'thinking', thinking: [{ type: 'text', text }], closed: true }], + tool_calls: [], + })), + { content: [{ type: 'text', text: 'The answer ' }] }, + { content: 'is 391.' }, + ]; + const responseChunks = [ + ...deltas.map((delta) => ({ choices: [{ index: 0, delta, finish_reason: null }] })), + { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }, + { + choices: [], + usage: { prompt_tokens: 120, completion_tokens: 30, total_tokens: 150 }, + }, + ]; + const body = responseChunks + .map((chunk) => { + const payload = { + id: 'completion-1', + object: 'chat.completion.chunk', + created: 1, + model: 'zai-glm-5-2', + ...chunk, + }; + return `data: ${JSON.stringify(payload)}\n\n`; + }) + .join(''); + const fetch = vi.fn().mockResolvedValue( + new Response(`${body}data: [DONE]\n\n`, { + headers: { 'content-type': 'text/event-stream' }, + }), + ); + const model = createModel( + { + id: 'custom/zai-glm-5-2', + url: 'https://model.example.com/v1', + apiKey: 'test-key', + }, + fetch, + ); + const result = streamText({ model, prompt: 'Hello', maxRetries: 0 }); + const chunks = []; + for await (const chunk of result.fullStream) chunks.push(chunk); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][0]).toBe('https://model.example.com/v1/chat/completions'); + const requestBody = fetch.mock.calls[0][1]?.body; + expect(requestBody).not.toContain('"stream_options"'); + expect(chunks.filter((chunk) => chunk.type === 'error')).toEqual([]); + expect(chunks.filter((chunk) => chunk.type.startsWith('reasoning-'))).toEqual([ + { type: 'reasoning-start', id: 'reasoning-0' }, + { type: 'reasoning-delta', id: 'reasoning-0', text: 'Let me ' }, + { type: 'reasoning-delta', id: 'reasoning-0', text: 'think.' }, + { type: 'reasoning-end', id: 'reasoning-0' }, + ]); + await expect(result.text).resolves.toBe('The answer is 391.'); + await expect(result.totalUsage).resolves.toMatchObject({ + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b75395c0b5..3cb2002d4c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,8 +207,8 @@ catalogs: specifier: ^4.0.20 version: 4.0.20 '@ai-sdk/openai-compatible': - specifier: ^3.0.14 - version: 3.0.28 + specifier: ^3.0.44 + version: 3.0.44 '@ai-sdk/otel': specifier: ^1.0.66 version: 1.0.74 @@ -1209,7 +1209,7 @@ importers: version: 4.0.20(zod@3.25.76) '@ai-sdk/openai-compatible': specifier: 'catalog:' - version: 3.0.28(zod@3.25.76) + version: 3.0.44(zod@3.25.76) '@ai-sdk/otel': specifier: 'catalog:' version: 1.0.74(zod@3.25.76) @@ -4723,7 +4723,7 @@ importers: version: 4.0.39(zod@3.25.76) '@ai-sdk/openai-compatible': specifier: 'catalog:' - version: 3.0.28(zod@3.25.76) + version: 3.0.44(zod@3.25.76) '@apidevtools/json-schema-ref-parser': specifier: 12.0.2 version: 12.0.2 @@ -8772,6 +8772,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/openai-compatible@3.0.44': + resolution: {integrity: sha512-pK7mERd/aIJVtyQCe/nV3G3iEJCY1no8GHDOGbgpNC+petjSfsxj+ni0Q8WVVehTUiY9B04F3Xvir+icBgORCg==} + engines: {node: '>=22'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/openai@4.0.20': resolution: {integrity: sha512-/Hu+btLIO2zuYqCp//vBBAGdM/BeN4gds+NWrlv7Y9WrcTXwnEEwh6P3QZIWh2Tt1I1lswuDIpAJ4t/c/rws3Q==} engines: {node: '>=22'} @@ -8806,6 +8812,16 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/provider-utils@5.0.36': + resolution: {integrity: sha512-MFXBn6XDyf37PNQAge/HTatPJE8Vmg/g/w4WPtjSV53jq8FKAzoaN5+43hsdQa9bqgN+/13jxug9ChvsG+godQ==} + engines: {node: '>=22'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/provider@4.0.10': + resolution: {integrity: sha512-fX2ENAc7iDpZ+Wp4+Rk06Usn/Ys7dI9uAkGv0jlF6XVrW13NkRWx5Ou+U6lIM2E1fTLkCs16GGrUAaVvroag7A==} + engines: {node: '>=22'} + '@ai-sdk/provider@4.0.3': resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} engines: {node: '>=22'} @@ -25311,6 +25327,12 @@ snapshots: '@ai-sdk/provider-utils': 5.0.25(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai-compatible@3.0.44(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 4.0.10 + '@ai-sdk/provider-utils': 5.0.36(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/openai@4.0.20(zod@3.25.76)': dependencies: '@ai-sdk/provider': 4.0.3 @@ -25360,6 +25382,19 @@ snapshots: undici: 7.29.1 zod: 3.25.76 + '@ai-sdk/provider-utils@5.0.36(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 4.0.10 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.0.8 + undici: 7.29.1 + zod: 3.25.76 + + '@ai-sdk/provider@4.0.10': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@4.0.3': dependencies: json-schema: 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bd6e587c175..7acdb1aafe3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -65,7 +65,7 @@ catalog: '@ai-sdk/mistral': ^4.0.14 '@ai-sdk/moonshotai': ^3.0.35 '@ai-sdk/openai': ^4.0.20 - '@ai-sdk/openai-compatible': ^3.0.14 + '@ai-sdk/openai-compatible': ^3.0.44 '@ai-sdk/otel': ^1.0.66 '@ai-sdk/provider-utils': ^5.0.27 '@ai-sdk/xai': ^4.0.18