test: add Ollama streaming pattern tests for thinking models

Add comprehensive tests verifying correct handling of Ollama's
non-standard streaming format used by thinking models (kimi-k2.5, etc.):

- NativeToolCallParser: Test single-chunk tool calls with non-standard
  IDs (functions.read_file:0), finalization via both finalizeRawChunks
  and processFinishReason, multiple sequential tool calls
- OpenAI handler: Test reasoning_content and reasoning field extraction,
  single-chunk tool call with non-standard ID, non-streaming response
  with reasoning and tool calls

These tests verify the fix from the previous commit works correctly
with Ollama's /v1/ endpoint behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
neorrk 2026-02-16 21:34:03 +01:00
parent 61778d2ad9
commit c51cf58c61
2 changed files with 397 additions and 0 deletions

View file

@ -1280,3 +1280,256 @@ describe("getOpenAiModels", () => {
expect(result).toEqual(["gpt-4", "gpt-3.5-turbo"])
})
})
describe("Ollama streaming patterns", () => {
// These tests verify that the OpenAI handler correctly processes
// Ollama's non-standard streaming format for thinking models like kimi-k2.5.
// Ollama differs from OpenAI in several ways:
// 1. Tool call IDs use "functions.tool_name:N" format (not "call_xxx")
// 2. Entire tool calls arrive in a single chunk (not incrementally)
// 3. reasoning_content field for thinking tokens
// 4. role: "assistant" on every delta (not just first)
// 5. content: "" (empty string) on deltas
let handler: OpenAiHandler
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: [{ type: "text" as const, text: "Read /etc/hostname" }] },
]
beforeEach(() => {
handler = new OpenAiHandler({
openAiApiKey: "test-key",
openAiModelId: "kimi-k2.5:cloud",
openAiBaseUrl: "http://localhost:4141/v1",
})
mockCreate.mockClear()
})
it("should yield reasoning chunks from reasoning_content field", async () => {
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { role: "assistant", reasoning_content: "Let me think about " },
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: { role: "assistant", reasoning_content: "this request." },
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: { role: "assistant", content: "Here is the result." },
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: {},
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}
},
}))
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(2)
expect(reasoningChunks[0].text).toBe("Let me think about ")
expect(reasoningChunks[1].text).toBe("this request.")
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Here is the result.")
})
it("should yield reasoning chunks from non-standard 'reasoning' field (Ollama)", async () => {
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { role: "assistant", reasoning: "Thinking via Ollama field" },
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: { role: "assistant", content: "Done." },
finish_reason: null,
},
],
}
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
}
},
}))
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0].text).toBe("Thinking via Ollama field")
})
it("should handle Ollama single-chunk tool call with non-standard ID", async () => {
// This is the exact pattern Ollama sends for kimi-k2.5 tool calls:
// reasoning chunks → single tool call chunk → finish_reason: "tool_calls"
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
// Reasoning chunks
yield {
choices: [
{
delta: { role: "assistant", reasoning_content: "I should read the file." },
finish_reason: null,
},
],
}
// Single chunk with complete tool call (Ollama style)
yield {
choices: [
{
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "functions.read_file:0",
type: "function",
function: {
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
},
},
],
},
finish_reason: null,
},
],
}
// Empty delta then finish
yield {
choices: [
{
delta: { role: "assistant" },
finish_reason: "tool_calls",
},
],
}
// Usage
yield {
choices: [],
usage: { prompt_tokens: 57, completion_tokens: 44, total_tokens: 101 },
}
},
}))
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have reasoning chunk
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0].text).toBe("I should read the file.")
// Should have tool_call_partial with Ollama-style ID
const toolPartials = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolPartials).toHaveLength(1)
expect(toolPartials[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "functions.read_file:0",
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
})
// Should have tool_call_end when finish_reason is "tool_calls"
const toolEnds = chunks.filter((c) => c.type === "tool_call_end")
expect(toolEnds).toHaveLength(1)
expect(toolEnds[0].id).toBe("functions.read_file:0")
// Should have usage
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
})
it("should handle non-streaming Ollama response with reasoning and tool calls", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
role: "assistant",
content: null,
reasoning_content: "Let me read the file.",
tool_calls: [
{
id: "functions.read_file:0",
type: "function",
function: {
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 50, completion_tokens: 40, total_tokens: 90 },
})
const nonStreamHandler = new OpenAiHandler({
openAiApiKey: "test-key",
openAiModelId: "kimi-k2.5:cloud",
openAiBaseUrl: "http://localhost:4141/v1",
openAiStreamingEnabled: false,
})
const stream = nonStreamHandler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have tool_call
const toolCalls = chunks.filter((c) => c.type === "tool_call")
expect(toolCalls).toHaveLength(1)
expect(toolCalls[0].id).toBe("functions.read_file:0")
expect(toolCalls[0].name).toBe("read_file")
// Should have reasoning
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0].text).toBe("Let me read the file.")
})
})

View file

@ -343,4 +343,148 @@ describe("NativeToolCallParser", () => {
})
})
})
describe("Ollama single-chunk tool call pattern", () => {
// Ollama sends the entire tool call (id + name + full arguments) in a single chunk,
// unlike OpenAI which streams them incrementally across multiple chunks.
// Ollama also uses non-standard tool call IDs like "functions.read_file:0"
// instead of OpenAI's "call_abc123" format.
it("should handle Ollama-style single-chunk tool call with non-standard ID", () => {
// Simulate exactly what Ollama sends through the proxy:
// One chunk with id, name, and complete arguments all at once
const events = NativeToolCallParser.processRawChunk({
index: 0,
id: "functions.read_file:0",
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
})
// Should emit tool_call_start followed by tool_call_delta
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events[0].type).toBe("tool_call_start")
expect(events[0]).toEqual({
type: "tool_call_start",
id: "functions.read_file:0",
name: "read_file",
})
expect(events[1].type).toBe("tool_call_delta")
expect(events[1]).toEqual({
type: "tool_call_delta",
id: "functions.read_file:0",
delta: '{"path":"/etc/hostname"}',
})
})
it("should finalize Ollama tool call via finalizeRawChunks", () => {
// Step 1: Process the single chunk (simulating what Task.ts does)
const rawEvents = NativeToolCallParser.processRawChunk({
index: 0,
id: "functions.read_file:0",
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
})
// Step 2: Start streaming tool call (simulating Task.ts tool_call_start handler)
const startEvent = rawEvents.find((e) => e.type === "tool_call_start")
expect(startEvent).toBeDefined()
NativeToolCallParser.startStreamingToolCall(
startEvent!.id,
startEvent!.type === "tool_call_start" ? (startEvent as any).name : "",
)
// Step 3: Process delta (simulating Task.ts tool_call_delta handler)
const deltaEvent = rawEvents.find((e) => e.type === "tool_call_delta")
expect(deltaEvent).toBeDefined()
if (deltaEvent?.type === "tool_call_delta") {
NativeToolCallParser.processStreamingChunk(deltaEvent.id, deltaEvent.delta)
}
// Step 4: Finalize via finalizeRawChunks (simulating end of stream in Task.ts)
// This is what happens when the stream ends — Task.ts calls finalizeRawChunks()
const finalEvents = NativeToolCallParser.finalizeRawChunks()
expect(finalEvents.length).toBe(1)
expect(finalEvents[0].type).toBe("tool_call_end")
expect(finalEvents[0].id).toBe("functions.read_file:0")
// Step 5: Finalize the streaming tool call (simulating Task.ts handling tool_call_end)
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall("functions.read_file:0")
expect(finalToolUse).not.toBeNull()
expect(finalToolUse?.type).toBe("tool_use")
if (finalToolUse?.type === "tool_use") {
expect(finalToolUse.name).toBe("read_file")
const nativeArgs = finalToolUse.nativeArgs as { path: string }
expect(nativeArgs.path).toBe("/etc/hostname")
}
})
it("should finalize Ollama tool call via processFinishReason", () => {
// Step 1: Process the single chunk
NativeToolCallParser.processRawChunk({
index: 0,
id: "functions.read_file:0",
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
})
// Step 2: Process finish_reason (Ollama sends finish_reason: "tool_calls")
const endEvents = NativeToolCallParser.processFinishReason("tool_calls")
expect(endEvents.length).toBe(1)
expect(endEvents[0].type).toBe("tool_call_end")
expect(endEvents[0].id).toBe("functions.read_file:0")
})
it("should handle Ollama execute_command tool call", () => {
const rawEvents = NativeToolCallParser.processRawChunk({
index: 0,
id: "functions.execute_command:0",
name: "execute_command",
arguments: '{"command":"ls -la","cwd":"/tmp"}',
})
expect(rawEvents.length).toBeGreaterThanOrEqual(2)
// Start and process streaming
NativeToolCallParser.startStreamingToolCall("functions.execute_command:0", "execute_command")
const deltaEvent = rawEvents.find((e) => e.type === "tool_call_delta")
if (deltaEvent?.type === "tool_call_delta") {
NativeToolCallParser.processStreamingChunk(deltaEvent.id, deltaEvent.delta)
}
// Finalize
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall("functions.execute_command:0")
expect(finalToolUse).not.toBeNull()
expect(finalToolUse?.type).toBe("tool_use")
if (finalToolUse?.type === "tool_use") {
expect(finalToolUse.name).toBe("execute_command")
const nativeArgs = finalToolUse.nativeArgs as { command: string; cwd?: string }
expect(nativeArgs.command).toBe("ls -la")
expect(nativeArgs.cwd).toBe("/tmp")
}
})
it("should handle multiple Ollama tool calls in sequence", () => {
// First tool call
const events1 = NativeToolCallParser.processRawChunk({
index: 0,
id: "functions.read_file:0",
name: "read_file",
arguments: '{"path":"/etc/hostname"}',
})
expect(events1[0].type).toBe("tool_call_start")
// Second tool call
const events2 = NativeToolCallParser.processRawChunk({
index: 1,
id: "functions.read_file:1",
name: "read_file",
arguments: '{"path":"/etc/hosts"}',
})
expect(events2[0].type).toBe("tool_call_start")
// Finish both via processFinishReason
const endEvents = NativeToolCallParser.processFinishReason("tool_calls")
expect(endEvents.length).toBe(2)
})
})
})