diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index 726fe55ad49..2f95c5dbdc9 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -88,6 +88,7 @@ interface NativeStreamToolCall { nativeIndex?: number; arguments: Record; argumentBytes: number; + metadataBytes: number; } interface NativeStreamState { @@ -106,6 +107,10 @@ type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]); const NATIVE_TOOL_ID_MAX_LENGTH = 256; +const NATIVE_TOOL_NAME_MAX_BYTES = 1024; +const NATIVE_MAX_PENDING_TOOL_CALLS = 128; +// Account for the retained Map key and call bookkeeping in addition to the provider's name. +const NATIVE_TOOL_CALL_BOOKKEEPING_BYTES = 128; const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u; function isRecord(value: unknown): value is JsonRecord { @@ -598,6 +603,10 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg } const fn = rawCall.function; if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name"); + const nameBytes = new TextEncoder().encode(fn.name).byteLength; + if (nameBytes > NATIVE_TOOL_NAME_MAX_BYTES) { + throw new Error(`ollama-native response tool call name exceeded ${NATIVE_TOOL_NAME_MAX_BYTES} bytes`); + } const args = assertObjectArguments(fn.arguments, "response tool call"); const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined; const nativeId = validNativeToolCallId(rawCall.id); @@ -610,6 +619,9 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg 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) { @@ -627,12 +639,25 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg ...(index !== undefined ? { nativeIndex: index } : {}), arguments: args, argumentBytes: 0, + metadataBytes: 0, }; budget.openCall(call.budgetKey); try { + const metadataBytes = nameBytes + NATIVE_TOOL_CALL_BOOKKEEPING_BYTES; + // Name and bookkeeping are retained call metadata, not arguments: charging them to the + // call's budget key would consume the per-call argument allowance. Keep them on the + // shared retained budget and release them when the call closes. + budget.chargeRetained(metadataBytes, { + kind: "tool_args", + }); + call.metadataBytes = metadataBytes; replaceNativeToolArguments(call, args, budget); state.toolCalls.set(key, call); } catch (error) { + if (call.metadataBytes > 0) { + budget.releaseRetained(call.metadataBytes, { kind: "tool_args" }); + call.metadataBytes = 0; + } budget.closeCall(call.budgetKey); throw error; } @@ -687,7 +712,13 @@ function replaceNativeToolArguments( } function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void { - for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey); + for (const call of state.toolCalls.values()) { + if (call.metadataBytes > 0) { + budget.releaseRetained(call.metadataBytes, { kind: "tool_args" }); + call.metadataBytes = 0; + } + budget.closeCall(call.budgetKey); + } } function nativeBodyMessage(value: unknown): JsonRecord { diff --git a/tests/providers/ollama/ollama-native-parser.test.ts b/tests/providers/ollama/ollama-native-parser.test.ts index 71bfad723ae..76f9194b646 100644 --- a/tests/providers/ollama/ollama-native-parser.test.ts +++ b/tests/providers/ollama/ollama-native-parser.test.ts @@ -376,6 +376,62 @@ describe("ollama-native — tool calls", () => { expect(both.filter(e => e.type === "tool_call_start")).toHaveLength(2); }); + test("rejects oversized tool names and too many pending calls", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const oversized = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "x".repeat(1025), arguments: {} } }], + }, false), + ])); + expect(oversized.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((oversized.at(-1) as { message?: string }).message).toContain("name exceeded 1024 bytes"); + + // The limit counts UTF-8 bytes, not JS code units: 513 multibyte characters exceed it even + // though the string is shorter than 1024 code units, while exactly 1024 bytes is accepted. + const oversizedUtf8 = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "\u00e9".repeat(513), arguments: {} } }], + }, true), + ])); + expect(oversizedUtf8.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((oversizedUtf8.at(-1) as { message?: string }).message).toContain("name exceeded 1024 bytes"); + + const boundaryUtf8 = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "\u00e9".repeat(512), arguments: {} } }], + }, true), + ])); + expect(boundaryUtf8.filter(e => e.type === "tool_call_start")).toHaveLength(1); + + const calls = Array.from({ length: 129 }, (_, index) => ({ + index, + function: { name: `tool_${index}`, arguments: {} }, + })); + const excessive = await collect(adapter, ndjsonResponse([ + frame({ role: "assistant", tool_calls: calls }, false), + ])); + expect(excessive.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((excessive.at(-1) as { message?: string }).message).toContain("exceeded 128 pending tool calls"); + }); + + test("charges retained tool names to the aggregate translator budget", async () => { + const adapter = createOllamaNativeAdapter(provider()); + // 2000 sits below the three name+ledger charges alone (3 x (700 + 128) = 2484), so the + // overflow can no longer hide behind the buffered JSON line the way 2500 let it. + const budget = createTestTranslatorBudget({ maxTurnBytes: 2000 }); + const frames = Array.from({ length: 3 }, (_, index) => frame({ + role: "assistant", + tool_calls: [{ function: { index, name: `${index}${"n".repeat(699)}`, arguments: {} } }], + }, false)); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(budget.snapshot().overflows).toBe(1); + }); + test("tool-result replay pairs a toolResult message with its call id", () => { const adapter = createOllamaNativeAdapter(provider()); const built = adapter.buildRequest(parsedWith([