Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ be configured on a separately named custom or self-hosted Ollama provider with
is refused rather than mis-sent, and remote image URLs are not fetched.
- **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with
object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name.
Valid native call IDs distinguish calls even when the provider repeats a function index.
Repeated IDs update the same call's arguments; ID-less entries fall back to index, then position.
Calls retain first-seen order. With `parallel_tool_calls: false`, a second distinct provider call
still fails the turn closed before any tool call is emitted.
Codex may record assistant commentary before a pending call's results. Text/thinking with no
new tool calls is deferred until the batch is settled, so genuine results remain beside their
originating calls. A new tool-call batch still settles the preceding one; missing results retain
Expand Down
32 changes: 18 additions & 14 deletions src/adapters/ollama-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ interface PendingToolBatch {
}

interface NativeStreamToolCall {
key: string;
fallbackKey: string;
budgetKey: string;
order: number;
name: string;
Expand Down Expand Up @@ -639,6 +639,7 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
}
if (message.tool_calls !== undefined) {
if (!Array.isArray(message.tool_calls)) throw new Error("ollama-native response tool_calls was not an array");
const frameCalls = new Map<NativeStreamToolCall, string | undefined>();
for (let position = 0; position < message.tool_calls.length; position++) {
const rawCall = message.tool_calls[position];
if (!isRecord(rawCall) || !isRecord(rawCall.function)) {
Expand All @@ -653,29 +654,31 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
const args = assertObjectArguments(fn.arguments, "response tool call");
const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined;
const nativeId = validNativeToolCallId(rawCall.id);
const key = index === undefined ? `position:${position}` : `index:${index}`;
// Tool-call identity is explicitly keyed by the provider index when supplied: a later
// frame for the same index updates that call's arguments, while a distinct index creates a
// second call. This narrow tool-call compatibility rule is independent from text/thinking
// semantics, where every non-empty native field is an appended partial delta.
const existing = state.toolCalls.get(key);
const fallbackKey = index === undefined ? `position:${position}` : `index:${index}`;
// Native ids distinguish calls even when Ollama repeats index zero. Index/position are
// fallback identities only; within one frame only entries sharing an id may update a call.
// An id arriving for a call first seen without one still adopts that call by its fallback.
const available = (call: NativeStreamToolCall) =>
!frameCalls.has(call) || (nativeId !== undefined && frameCalls.get(call) === nativeId);
const calls = [...state.toolCalls.values()];
const existing = (nativeId ? calls.find(call => call.nativeId === nativeId && available(call)) : undefined)
?? calls.find(call => call.fallbackKey === fallbackKey
&& (!nativeId || !call.nativeId) && available(call));
if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) {
throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested");
}
if (!existing && state.toolCalls.size >= NATIVE_MAX_PENDING_TOOL_CALLS) {
throw new Error(`ollama-native response exceeded ${NATIVE_MAX_PENDING_TOOL_CALLS} pending tool calls`);
}
if (existing) {
if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function");
if (nativeId && existing.nativeId && nativeId !== existing.nativeId) {
throw new Error("ollama-native response changed a tool-call id for an existing index");
}
if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call identity for another function");
if (!existing.nativeId && nativeId) existing.nativeId = nativeId;
replaceNativeToolArguments(existing, args, budget);
frameCalls.set(existing, nativeId);
} else {
const call: NativeStreamToolCall = {
key,
budgetKey: `ollama-native:${key}`,
fallbackKey,
budgetKey: `ollama-native:${state.nextToolOrder}`,
order: state.nextToolOrder++,
name: fn.name,
...(nativeId ? { nativeId } : {}),
Expand All @@ -695,7 +698,8 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
});
call.metadataBytes = metadataBytes;
replaceNativeToolArguments(call, args, budget);
state.toolCalls.set(key, call);
state.toolCalls.set(call.budgetKey, call);
frameCalls.set(call, nativeId);
} catch (error) {
if (call.metadataBytes > 0) {
budget.releaseRetained(call.metadataBytes, { kind: "tool_args" });
Expand Down
2 changes: 1 addition & 1 deletion structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ with no new tool calls is also deferred while results remain outstanding. Deferr
A new tool-call batch settles its predecessor. Additional results in an open batch join in arrival
order, preserving images and error markers; known late output uses an attributed user-text carrier
after any pending batch. Unknown IDs and mismatched names/namespaces still throw; missing results
retain the unknown-status marker; the replay and tool-continuation tests under `tests/providers/ollama/` cover both paths. See the decision record below.
retain the unknown-status marker; the replay and tool-continuation tests under `tests/providers/ollama/` cover both paths. See the decision record below. Native response tool-call identity uses valid IDs before function-index/position fallbacks. Distinct IDs at index zero remain separate; repeated IDs replace arguments and cannot change function names. ID-less later frames can update an existing index (and an id may adopt a call first seen without one), but same-frame entries share a call only with the same ID. First-seen order, separate call budgets/retained metadata and the 128-call cap remain enforced. `parallelToolCalls:false` still fails closed before actionable tool events. `tests/providers/ollama/ollama-native-parser.test.ts` covers these contracts.

Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses
API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit,
Expand Down
212 changes: 212 additions & 0 deletions tests/providers/ollama/ollama-native-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,218 @@ describe("ollama-native — buffered terminal contract", () => {
});

describe("ollama-native — tool calls", () => {
const capturedFrame = {
message: {
role: "assistant", content: "", tool_calls: [
{ id: "PpZ8JqRmm", function: { index: 0, name: "read_file", arguments: { path: "/etc/hostname" } } },
{ id: "DDe4wZqkx", function: { index: 0, name: "read_file", arguments: { path: "/etc/os-release" } } },
],
},
done: false,
};
const toolTerminal = { done: true, done_reason: "tool_calls" };

test.each(["stream", "buffered"])("native identity: captured index-zero calls stay distinct (%s)", async mode => {
const adapter = createOllamaNativeAdapter(provider());
adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: true }));
const budget = createTestTranslatorBudget({ maxCallArgumentBytes: 32 });
const events: AdapterEvent[] = [];
if (mode === "buffered") {
events.push(...await adapter.parseResponse!(new Response(JSON.stringify({ ...capturedFrame, ...toolTerminal })), budget));
} else {
for await (const event of adapter.parseStream(ndjsonResponse([capturedFrame, toolTerminal]), budget)) events.push(event);
}
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "PpZ8JqRmm", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"/etc/hostname"}' },
{ type: "tool_call_end" },
{ type: "tool_call_start", id: "DDe4wZqkx", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"/etc/os-release"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
expect(budget.snapshot()).toMatchObject({ activeCalls: 0, overflows: 0 });
// Buffered parsing retains the translated event batch until its response owner disposes it.
if (mode === "stream") expect(budget.snapshot().currentBytes).toBe(0);
budget.dispose();
expect(budget.snapshot().currentBytes).toBe(0);
});

test("native identity: captured parallel calls fail at the parallel policy guard", async () => {
const adapter = createOllamaNativeAdapter(provider());
adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false }));
const budget = createTestTranslatorBudget();
const events: AdapterEvent[] = [];
for await (const event of adapter.parseStream(ndjsonResponse([capturedFrame, toolTerminal]), budget)) events.push(event);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" });
expect((events[0] as { message: string }).message).toContain("parallel tool calls");
expect((events[0] as { message: string }).message).not.toContain("changed a tool-call id");
expect(events.some(event => event.type === "tool_call_start")).toBe(false);
expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 });
});

test.each([0, 1])("native identity: repeated id replaces arguments even at index %i", async nextIndex => {
const adapter = createOllamaNativeAdapter(provider());
adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false }));
const events = await collect(adapter, ndjsonResponse([
frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old", stale: true } } }] }, false),
frame({ tool_calls: [{ id: "stable", function: { index: nextIndex, name: "read_file", arguments: { path: "new" } } }] }, false),
toolTerminal,
]));
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "stable", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"new"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
});

test.each(["index", "position"])("native identity: id-less %s updates across frames", async identity => {
const index = identity === "index" ? { index: 3 } : {};
const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([
frame({ tool_calls: [{ function: { ...index, name: "read_file", arguments: { path: "old", stale: true } } }] }, false),
frame({ tool_calls: [{ function: { ...index, name: "read_file", arguments: { path: "new" } } }] }, false),
toolTerminal,
]));
const starts = events.filter(event => event.type === "tool_call_start");
expect(starts).toHaveLength(1);
expect(starts[0]).toMatchObject({ name: "read_file", id: expect.stringMatching(/^ollama_call_/) });
expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: '{"path":"new"}' }]);
expect(events.at(-1)).toEqual({ type: "done", stopReason: "tool_calls" });
});

test("native identity: repeated id in one frame replaces arguments despite differing indexes", async () => {
const adapter = createOllamaNativeAdapter(provider());
adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false }));
const events = await collect(adapter, ndjsonResponse([
frame({ tool_calls: [
{ id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old" } } },
{ id: "stable", function: { index: 1, name: "read_file", arguments: { path: "new" } } },
] }, false),
toolTerminal,
]));
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "stable", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"new"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
});

test.each([0, 1])("native identity: id-less entries in one frame stay distinct at index %i", async secondIndex => {
const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([
frame({ tool_calls: [
{ function: { index: 0, name: "read_file", arguments: { path: "old-first" } } },
{ function: { index: secondIndex, name: "read_file", arguments: { path: "old-second" } } },
] }, false),
frame({ tool_calls: [
{ function: { index: 0, name: "read_file", arguments: { path: "first" } } },
{ function: { index: secondIndex, name: "read_file", arguments: { path: "second" } } },
] }, false),
toolTerminal,
]));
const starts = events.filter(event => event.type === "tool_call_start");
expect(starts).toHaveLength(2);
expect(starts.map(event => event.name)).toEqual(["read_file", "read_file"]);
expect(new Set(starts.map(event => event.id)).size).toBe(2);
expect(events.filter(event => event.type === "tool_call_delta")).toEqual([
{ type: "tool_call_delta", arguments: '{"path":"first"}' },
{ type: "tool_call_delta", arguments: '{"path":"second"}' },
]);
expect(events.at(-1)).toEqual({ type: "done", stopReason: "tool_calls" });
});

test("native identity: an id-less index updates an existing native-id call", async () => {
const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([
frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old" } } }] }, false),
frame({ tool_calls: [{ function: { index: 0, name: "read_file", arguments: { path: "new" } } }] }, false),
toolTerminal,
]));
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "stable", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"new"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
});

test("native identity: a later id adopts the id-less call at its index", async () => {
const adapter = createOllamaNativeAdapter(provider());
adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false }));
const events = await collect(adapter, ndjsonResponse([
frame({ tool_calls: [{ function: { index: 0, name: "read_file", arguments: { path: "old" } } }] }, false),
frame({ tool_calls: [{ id: "late", function: { index: 0, name: "read_file", arguments: { path: "new" } } }] }, false),
toolTerminal,
]));
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "late", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"new"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
});

test.each([0, 1])("native identity: the same id cannot change function at index %i", async nextIndex => {
const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([
frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: {} } }] }, false),
frame({ tool_calls: [{ id: "stable", function: { index: nextIndex, name: "write_file", arguments: {} } }] }, false),
toolTerminal,
]));
expect(events.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" });
expect((events.at(-1) as { message: string }).message).toContain("another function");
expect(events.some(event => event.type === "tool_call_start")).toBe(false);
});

test("native identity: different ids and functions at one index across frames preserve order", async () => {
const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([
frame({ tool_calls: [{ id: "first", function: { index: 0, name: "read_file", arguments: { path: "first" } } }] }, false),
frame({ tool_calls: [{ id: "second", function: { index: 0, name: "write_file", arguments: { path: "second" } } }] }, false),
frame({ tool_calls: [{ id: "first", function: { index: 0, name: "read_file", arguments: { path: "updated" } } }] }, false),
toolTerminal,
]));
expect(events.filter(event => event.type !== "heartbeat")).toEqual([
{ type: "tool_call_start", id: "first", name: "read_file" },
{ type: "tool_call_delta", arguments: '{"path":"updated"}' },
{ type: "tool_call_end" },
{ type: "tool_call_start", id: "second", name: "write_file" },
{ type: "tool_call_delta", arguments: '{"path":"second"}' },
{ type: "tool_call_end" },
{ type: "done", stopReason: "tool_calls" },
]);
});

test("native identity: the pending-call cap counts distinct ids at one index", async () => {
const adapter = createOllamaNativeAdapter(provider());
const calls = Array.from({ length: 129 }, (_, index) => ({
id: `call_${index}`, function: { index: 0, name: "read_file", arguments: {} },
}));
const accepted = await collect(adapter, ndjsonResponse([
frame({ tool_calls: calls.slice(0, 128) }, false), toolTerminal,
]));
expect(accepted.filter(event => event.type === "tool_call_start").map(event => event.id)).toEqual(calls.slice(0, 128).map(call => call.id));
expect(accepted.at(-1)?.type).toBe("done");
const budget = createTestTranslatorBudget();
const rejected: AdapterEvent[] = [];
for await (const event of adapter.parseStream(ndjsonResponse([frame({ tool_calls: calls }, false)]), budget)) rejected.push(event);
expect(rejected.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" });
expect((rejected.at(-1) as { message: string }).message).toContain("exceeded 128 pending tool calls");
expect(rejected.some(event => event.type === "tool_call_start")).toBe(false);
expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 });
});

test("native identity: distinct ids at one index retain separate metadata charges", async () => {
const budget = createTestTranslatorBudget({ maxTurnBytes: 2000 });
const frames = Array.from({ length: 3 }, (_, index) => frame({ tool_calls: [{
id: `call_${index}`, function: { index: 0, name: "n".repeat(700), arguments: {} },
}] }, false));
const events: AdapterEvent[] = [];
for await (const event of createOllamaNativeAdapter(provider()).parseStream(ndjsonResponse(frames), budget)) events.push(event);
expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" });
expect(events.some(event => event.type === "tool_call_start")).toBe(false);
expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 });
});

test("indexed tool calls preserve order and identity", async () => {
const adapter = createOllamaNativeAdapter(provider());
const budget = createTestTranslatorBudget();
Expand Down
Loading