diff --git a/.changeset/ollama-stream-length.md b/.changeset/ollama-stream-length.md new file mode 100644 index 0000000000..ca1473db6b --- /dev/null +++ b/.changeset/ollama-stream-length.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-ollama': patch +--- + +Preserve Ollama's `done_reason: "length"` in streamed chat completions, including turns with tool calls, so truncated output does not execute tools. diff --git a/docs/adapters/ollama.md b/docs/adapters/ollama.md index 2f8e695b6b..475180b675 100644 --- a/docs/adapters/ollama.md +++ b/docs/adapters/ollama.md @@ -212,6 +212,8 @@ const stream = chat({ Sampling options do not go at the root of `chat()`. For Ollama, they go to `modelOptions.options.temperature`, `modelOptions.options.top_p`, and `modelOptions.options.num_predict`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). The full option list is in [All runner options](#all-runner-options). +When Ollama reports `done_reason: "length"`, the final `RUN_FINISHED` event carries `metadata.tanstack.finishReason: "length"`. This also applies when the stream contains tool calls. `chat()` keeps the partial reply and skips tool execution for that truncated turn. + ### Reasoning Running a thinking model like Qwen 3 or gpt-oss? Set `reasoning` on `chat()`: diff --git a/docs/config.json b/docs/config.json index 359b32483f..143109838f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1715,7 +1715,7 @@ "label": "Ollama", "to": "adapters/ollama", "addedAt": "2026-04-15", - "updatedAt": "2026-10-22" + "updatedAt": "2026-10-25" }, { "label": "Grok (xAI)", diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index 67a456aba6..68a0e8f231 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -432,7 +432,12 @@ export class OllamaTextAdapter extends BaseTextAdapter< threadId, model: chunk.model, timestamp: Date.now(), - finishReason: toolCallsEmitted.size > 0 ? 'tool_calls' : 'stop', + finishReason: + chunk.done_reason === 'length' + ? 'length' + : toolCallsEmitted.size > 0 + ? 'tool_calls' + : 'stop', // usage is optional under exactOptionalPropertyTypes; omit the key // entirely when Ollama reported no token counts. ...(finishUsage && { usage: finishUsage }), diff --git a/packages/ai-ollama/tests/stream-finish-reason.test.ts b/packages/ai-ollama/tests/stream-finish-reason.test.ts new file mode 100644 index 0000000000..10c86c6dd0 --- /dev/null +++ b/packages/ai-ollama/tests/stream-finish-reason.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { chat } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { createOllamaChat } from '../src/adapters/text' + +afterEach(() => vi.unstubAllGlobals()) + +describe('Ollama stream finish reason', () => { + it.each([ + { doneReason: 'length', toolCall: false, expected: 'length' }, + { doneReason: 'length', toolCall: true, expected: 'length' }, + { doneReason: 'stop', toolCall: false, expected: 'stop' }, + { doneReason: 'stop', toolCall: true, expected: 'tool_calls' }, + { doneReason: undefined, toolCall: false, expected: 'stop' }, + { doneReason: undefined, toolCall: true, expected: 'tool_calls' }, + ])( + 'maps $doneReason with toolCall=$toolCall to $expected', + async ({ doneReason, toolCall, expected }) => { + const lines = [ + { + message: { + role: 'assistant', + content: 'Partial answer', + ...(toolCall && { + tool_calls: [{ function: { name: 'lookup', arguments: {} } }], + }), + }, + done: false, + }, + { + message: { role: 'assistant', content: '' }, + done: true, + done_reason: doneReason, + prompt_eval_count: 14, + eval_count: 5, + }, + ] + const fetch = vi.fn( + async () => + new Response( + lines.map((line) => JSON.stringify(line) + '\n').join(''), + { headers: { 'content-type': 'application/x-ndjson' } }, + ), + ) + vi.stubGlobal('fetch', fetch) + const adapter = createOllamaChat('llama3.2', 'http://ollama.test') + const messages = [{ role: 'user' as const, content: 'Reply' }] + const chunks = [] + for await (const chunk of adapter.chatStream({ + threadId: 'ollama-finish-reason', + logger: resolveDebugOption(false), + model: adapter.model, + messages, + })) + chunks.push(chunk) + + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_FINISHED', + finishReason: expected, + }) + + if (doneReason === 'length') { + const execute = vi.fn(() => 'result') + const events = [] + for await (const event of chat({ + threadId: 'ollama-finish-reason', + adapter, + messages, + tools: [{ name: 'lookup', description: 'Look up an item', execute }], + modelOptions: { model: adapter.model, options: { num_predict: 5 } }, + debug: false, + })) + events.push(event) + + expect(events.at(-1)).toMatchObject({ + type: 'RUN_FINISHED', + metadata: { tanstack: { finishReason: 'length' } }, + }) + expect(events.some((event) => event.type === 'RUN_ERROR')).toBe(false) + expect(execute).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledTimes(2) + } + }, + ) +}) diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 35cb311a76..7c8b0d40fa 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -149,6 +149,7 @@ import { Route as ApiMoonshotUsageDetailsRouteImport } from './routes/api.moonsh import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiNeonWireRouteImport } from './routes/api.neon-wire' import { Route as ApiNonStreamingRunErrorRouteImport } from './routes/api.non-streaming-run-error' +import { Route as ApiOllamaFinishReasonRouteImport } from './routes/api.ollama-finish-reason' import { Route as ApiOpenaiCompletedResponseTextRouteImport } from './routes/api.openai-completed-response-text' import { Route as ApiOpenaiComputerUseWireRouteImport } from './routes/api.openai-computer-use-wire' import { Route as ApiOpenaiImage25ModelsRouteImport } from './routes/api.openai-image-2-5-models' @@ -200,10 +201,10 @@ import { Route as ApiWebMcpPageToolsRouteImport } from './routes/api.web-mcp-pag import { Route as ApiWorldRouteImport } from './routes/api.world' import { Route as ApiWrapFetchWireRouteImport } from './routes/api.wrap-fetch-wire' import { Route as ApiAudioStreamRouteImport } from './routes/api.audio.stream' -import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' -import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessHandoffSplatRouteImport } from './routes/api.harness-handoff.$' import { Route as ApiHarnessObservabilitySplatRouteImport } from './routes/api.harness-observability.$' +import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' +import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessWaitsSplatRouteImport } from './routes/api.harness-waits.$' import { Route as ApiImageStreamRouteImport } from './routes/api.image.stream' import { Route as ApiTranscriptionStreamRouteImport } from './routes/api.transcription.stream' @@ -938,6 +939,11 @@ const ApiNonStreamingRunErrorRoute = ApiNonStreamingRunErrorRouteImport.update({ path: '/api/non-streaming-run-error', getParentRoute: () => rootRouteImport, } as any) +const ApiOllamaFinishReasonRoute = ApiOllamaFinishReasonRouteImport.update({ + id: '/api/ollama-finish-reason', + path: '/api/ollama-finish-reason', + getParentRoute: () => rootRouteImport, +} as any) const ApiOpenaiCompletedResponseTextRoute = ApiOpenaiCompletedResponseTextRouteImport.update({ id: '/api/openai-completed-response-text', @@ -1212,17 +1218,6 @@ const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ path: '/stream', getParentRoute: () => ApiAudioRoute, } as any) -const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ - id: '/api/harness-protocol/$', - path: '/api/harness-protocol/$', - getParentRoute: () => rootRouteImport, -} as any) -const ApiHarnessSecretAnswerSplatRoute = - ApiHarnessSecretAnswerSplatRouteImport.update({ - id: '/api/harness-secret-answer/$', - path: '/api/harness-secret-answer/$', - getParentRoute: () => rootRouteImport, - } as any) const ApiHarnessHandoffSplatRoute = ApiHarnessHandoffSplatRouteImport.update({ id: '/api/harness-handoff/$', path: '/api/harness-handoff/$', @@ -1234,6 +1229,17 @@ const ApiHarnessObservabilitySplatRoute = path: '/api/harness-observability/$', getParentRoute: () => rootRouteImport, } as any) +const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ + id: '/api/harness-protocol/$', + path: '/api/harness-protocol/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHarnessSecretAnswerSplatRoute = + ApiHarnessSecretAnswerSplatRouteImport.update({ + id: '/api/harness-secret-answer/$', + path: '/api/harness-secret-answer/$', + getParentRoute: () => rootRouteImport, + } as any) const ApiHarnessWaitsSplatRoute = ApiHarnessWaitsSplatRouteImport.update({ id: '/api/harness-waits/$', path: '/api/harness-waits/$', @@ -1400,6 +1406,7 @@ export interface FileRoutesByFullPath { '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/neon-wire': typeof ApiNeonWireRoute '/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute + '/api/ollama-finish-reason': typeof ApiOllamaFinishReasonRoute '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute '/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute @@ -1452,10 +1459,10 @@ export interface FileRoutesByFullPath { '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1602,6 +1609,7 @@ export interface FileRoutesByTo { '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/neon-wire': typeof ApiNeonWireRoute '/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute + '/api/ollama-finish-reason': typeof ApiOllamaFinishReasonRoute '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute '/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute @@ -1654,10 +1662,10 @@ export interface FileRoutesByTo { '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider': typeof ProviderIndexRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1805,6 +1813,7 @@ export interface FileRoutesById { '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/neon-wire': typeof ApiNeonWireRoute '/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute + '/api/ollama-finish-reason': typeof ApiOllamaFinishReasonRoute '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute '/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute @@ -1857,10 +1866,10 @@ export interface FileRoutesById { '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -2009,6 +2018,7 @@ export interface FileRouteTypes { | '/api/multimodal-tool-result-wire' | '/api/neon-wire' | '/api/non-streaming-run-error' + | '/api/ollama-finish-reason' | '/api/openai-completed-response-text' | '/api/openai-computer-use-wire' | '/api/openai-image-2-5-models' @@ -2061,10 +2071,10 @@ export interface FileRouteTypes { | '/api/wrap-fetch-wire' | '/$provider/' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2211,6 +2221,7 @@ export interface FileRouteTypes { | '/api/multimodal-tool-result-wire' | '/api/neon-wire' | '/api/non-streaming-run-error' + | '/api/ollama-finish-reason' | '/api/openai-completed-response-text' | '/api/openai-computer-use-wire' | '/api/openai-image-2-5-models' @@ -2263,10 +2274,10 @@ export interface FileRouteTypes { | '/api/wrap-fetch-wire' | '/$provider' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2413,6 +2424,7 @@ export interface FileRouteTypes { | '/api/multimodal-tool-result-wire' | '/api/neon-wire' | '/api/non-streaming-run-error' + | '/api/ollama-finish-reason' | '/api/openai-completed-response-text' | '/api/openai-computer-use-wire' | '/api/openai-image-2-5-models' @@ -2465,10 +2477,10 @@ export interface FileRouteTypes { | '/api/wrap-fetch-wire' | '/$provider/' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2616,6 +2628,7 @@ export interface RootRouteChildren { ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute ApiNeonWireRoute: typeof ApiNeonWireRoute ApiNonStreamingRunErrorRoute: typeof ApiNonStreamingRunErrorRoute + ApiOllamaFinishReasonRoute: typeof ApiOllamaFinishReasonRoute ApiOpenaiCompletedResponseTextRoute: typeof ApiOpenaiCompletedResponseTextRoute ApiOpenaiComputerUseWireRoute: typeof ApiOpenaiComputerUseWireRoute ApiOpenaiImage25ModelsRoute: typeof ApiOpenaiImage25ModelsRoute @@ -2667,10 +2680,10 @@ export interface RootRouteChildren { ApiWorldRoute: typeof ApiWorldRoute ApiWrapFetchWireRoute: typeof ApiWrapFetchWireRoute ProviderIndexRoute: typeof ProviderIndexRoute - ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute - ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute ApiHarnessHandoffSplatRoute: typeof ApiHarnessHandoffSplatRoute ApiHarnessObservabilitySplatRoute: typeof ApiHarnessObservabilitySplatRoute + ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute + ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute ApiHarnessWaitsSplatRoute: typeof ApiHarnessWaitsSplatRoute } @@ -3656,6 +3669,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiNonStreamingRunErrorRouteImport parentRoute: typeof rootRouteImport } + '/api/ollama-finish-reason': { + id: '/api/ollama-finish-reason' + path: '/api/ollama-finish-reason' + fullPath: '/api/ollama-finish-reason' + preLoaderRoute: typeof ApiOllamaFinishReasonRouteImport + parentRoute: typeof rootRouteImport + } '/api/openai-completed-response-text': { id: '/api/openai-completed-response-text' path: '/api/openai-completed-response-text' @@ -4013,20 +4033,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAudioStreamRouteImport parentRoute: typeof ApiAudioRoute } - '/api/harness-protocol/$': { - id: '/api/harness-protocol/$' - path: '/api/harness-protocol/$' - fullPath: '/api/harness-protocol/$' - preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport - parentRoute: typeof rootRouteImport - } - '/api/harness-secret-answer/$': { - id: '/api/harness-secret-answer/$' - path: '/api/harness-secret-answer/$' - fullPath: '/api/harness-secret-answer/$' - preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport - parentRoute: typeof rootRouteImport - } '/api/harness-handoff/$': { id: '/api/harness-handoff/$' path: '/api/harness-handoff/$' @@ -4041,6 +4047,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHarnessObservabilitySplatRouteImport parentRoute: typeof rootRouteImport } + '/api/harness-protocol/$': { + id: '/api/harness-protocol/$' + path: '/api/harness-protocol/$' + fullPath: '/api/harness-protocol/$' + preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/harness-secret-answer/$': { + id: '/api/harness-secret-answer/$' + path: '/api/harness-secret-answer/$' + fullPath: '/api/harness-secret-answer/$' + preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/harness-waits/$': { id: '/api/harness-waits/$' path: '/api/harness-waits/$' @@ -4282,6 +4302,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, ApiNeonWireRoute: ApiNeonWireRoute, ApiNonStreamingRunErrorRoute: ApiNonStreamingRunErrorRoute, + ApiOllamaFinishReasonRoute: ApiOllamaFinishReasonRoute, ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute, ApiOpenaiComputerUseWireRoute: ApiOpenaiComputerUseWireRoute, ApiOpenaiImage25ModelsRoute: ApiOpenaiImage25ModelsRoute, @@ -4334,10 +4355,10 @@ const rootRouteChildren: RootRouteChildren = { ApiWorldRoute: ApiWorldRoute, ApiWrapFetchWireRoute: ApiWrapFetchWireRoute, ProviderIndexRoute: ProviderIndexRoute, - ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, - ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, ApiHarnessHandoffSplatRoute: ApiHarnessHandoffSplatRoute, ApiHarnessObservabilitySplatRoute: ApiHarnessObservabilitySplatRoute, + ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, + ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, ApiHarnessWaitsSplatRoute: ApiHarnessWaitsSplatRoute, } export const routeTree = rootRouteImport diff --git a/testing/e2e/src/routes/api.ollama-finish-reason.ts b/testing/e2e/src/routes/api.ollama-finish-reason.ts new file mode 100644 index 0000000000..be8f2cbba8 --- /dev/null +++ b/testing/e2e/src/routes/api.ollama-finish-reason.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { createOllamaChat } from '@tanstack/ai-ollama' + +export const Route = createFileRoute('/api/ollama-finish-reason')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = new URL(request.url).searchParams + const toolCall = params.get('tool') === 'true' + const doneReason = params.get('reason') ?? 'length' + let requests = 0 + let executions = 0 + const events = [] + // Provider-free wire fixture: exercise the real Ollama SDK's NDJSON parser. + for await (const event of chat({ + threadId: 'ollama-finish-reason', + adapter: createOllamaChat('llama3.2', 'http://ollama.test'), + messages: [{ role: 'user', content: 'Reply' }], + modelOptions: { model: 'llama3.2', options: { num_predict: 5 } }, + debug: false, + wrapFetch: () => async () => { + requests++ + const lines = [ + { + message: { + role: 'assistant', + content: 'Partial answer', + ...(toolCall && + requests === 1 && { + tool_calls: [ + { function: { name: 'lookup', arguments: {} } }, + ], + }), + }, + done: false, + }, + { + message: { role: 'assistant', content: '' }, + done: true, + done_reason: doneReason, + }, + ] + return new Response( + lines.map((line) => JSON.stringify(line) + '\n').join(''), + { + headers: { 'content-type': 'application/x-ndjson' }, + }, + ) + }, + tools: [ + { + name: 'lookup', + description: 'Look up an item', + execute: () => { + executions++ + return 'result' + }, + }, + ], + })) + events.push(event) + return Response.json({ events, requests, executions }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/ollama-finish-reason.spec.ts b/testing/e2e/tests/ollama-finish-reason.spec.ts new file mode 100644 index 0000000000..ac2a4888b1 --- /dev/null +++ b/testing/e2e/tests/ollama-finish-reason.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from './fixtures' + +for (const tool of [false, true]) { + test(`Ollama preserves length without executing truncated tools (tool=${tool})`, async ({ + request, + }) => { + const response = await request.post( + `/api/ollama-finish-reason?tool=${tool}`, + ) + expect(response.ok()).toBe(true) + const body = await response.json() + expect(body).toMatchObject({ requests: 1, executions: 0 }) + expect(body.events.at(-1)).toMatchObject({ + type: 'RUN_FINISHED', + metadata: { tanstack: { finishReason: 'length' } }, + }) + expect( + body.events.some((event: { type: string }) => event.type === 'RUN_ERROR'), + ).toBe(false) + }) +} + +test('Ollama still executes a complete tool call', async ({ request }) => { + const response = await request.post( + '/api/ollama-finish-reason?tool=true&reason=stop', + ) + expect(response.ok()).toBe(true) + const body = await response.json() + expect(body).toMatchObject({ requests: 2, executions: 1 }) + expect(body.events.at(-1)).toMatchObject({ + type: 'RUN_FINISHED', + metadata: { tanstack: { finishReason: 'stop' } }, + }) +})