Skip to content

Commit a2bce18

Browse files
fix(ollama): support parsing structured output from tool calls (#9350)
Co-authored-by: Hunter Lovell <[email protected]>
1 parent 854471d commit a2bce18

File tree

3 files changed

+94
-7
lines changed

3 files changed

+94
-7
lines changed

.changeset/unlucky-keys-poke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@langchain/ollama": patch
3+
---
4+
5+
fix(ollama): support parsing structured output from tool calls

libs/providers/langchain-ollama/src/chat_models.ts

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
AIMessage,
3+
AIMessageChunk,
34
UsageMetadata,
45
type BaseMessage,
56
} from "@langchain/core/messages";
@@ -19,7 +20,6 @@ import {
1920
// @ts-ignore CJS type resolution workaround
2021
import { Ollama } from "ollama/browser";
2122
import { ChatGenerationChunk, ChatResult } from "@langchain/core/outputs";
22-
import { AIMessageChunk } from "@langchain/core/messages";
2323
import type {
2424
ChatRequest as OllamaChatRequest,
2525
ChatResponse as OllamaChatResponse,
@@ -28,6 +28,7 @@ import type {
2828
} from "ollama";
2929
import {
3030
Runnable,
31+
RunnableLambda,
3132
RunnablePassthrough,
3233
RunnableSequence,
3334
} from "@langchain/core/runnables";
@@ -40,6 +41,7 @@ import {
4041
import {
4142
InteropZodType,
4243
isInteropZodSchema,
44+
interopParseAsync,
4345
} from "@langchain/core/utils/types";
4446
import { toJsonSchema } from "@langchain/core/utils/json_schema";
4547
import {
@@ -680,9 +682,9 @@ export class ChatOllama
680682
runManager
681683
)) {
682684
if (!finalChunk) {
683-
finalChunk = chunk.message;
685+
finalChunk = chunk.message as AIMessageChunk;
684686
} else {
685-
finalChunk = concat(finalChunk, chunk.message);
687+
finalChunk = concat(finalChunk, chunk.message as AIMessageChunk);
686688
}
687689
}
688690

@@ -836,11 +838,12 @@ export class ChatOllama
836838
const jsonSchema = outputSchemaIsZod
837839
? toJsonSchema(outputSchema)
838840
: outputSchema;
841+
const functionName = config?.name ?? "extract";
839842
const llm = this.bindTools([
840843
{
841844
type: "function" as const,
842845
function: {
843-
name: "extract",
846+
name: functionName,
844847
description: jsonSchema.description,
845848
parameters: jsonSchema,
846849
},
@@ -852,9 +855,68 @@ export class ChatOllama
852855
schema: toJsonSchema(outputSchema),
853856
},
854857
});
855-
const outputParser = outputSchemaIsZod
856-
? StructuredOutputParser.fromZodSchema(outputSchema)
857-
: new JsonOutputParser<RunOutput>();
858+
859+
/**
860+
* Create a parser that handles both tool calls and JSON content
861+
*/
862+
const outputParser = RunnableLambda.from<BaseMessage, RunOutput>(
863+
async (input: BaseMessage): Promise<RunOutput> => {
864+
/**
865+
* Ensure input is an AI message (either AIMessage or AIMessageChunk)
866+
*/
867+
if (
868+
!AIMessage.isInstance(input) &&
869+
!AIMessageChunk.isInstance(input)
870+
) {
871+
throw new Error("Input is not an AIMessage or AIMessageChunk.");
872+
}
873+
874+
/**
875+
* First, check if there are tool calls - extract args from the tool call
876+
*/
877+
if (input.tool_calls && input.tool_calls.length > 0) {
878+
const toolCall = input.tool_calls.find(
879+
(tc) => tc.name === functionName
880+
);
881+
if (toolCall && toolCall.args) {
882+
/**
883+
* Validate with schema if Zod schema is provided
884+
*/
885+
if (outputSchemaIsZod) {
886+
return await interopParseAsync(
887+
outputSchema as InteropZodType<RunOutput>,
888+
toolCall.args
889+
);
890+
}
891+
return toolCall.args as RunOutput;
892+
}
893+
}
894+
895+
/**
896+
* Fallback: parse content as JSON (when format: "json" is set)
897+
*/
898+
const content =
899+
typeof input.content === "string" ? input.content : "";
900+
if (!content) {
901+
throw new Error(
902+
"No tool calls found and content is empty. Cannot parse structured output."
903+
);
904+
}
905+
906+
/**
907+
* Use the appropriate parser based on schema type
908+
*/
909+
if (outputSchemaIsZod) {
910+
const zodParser = StructuredOutputParser.fromZodSchema(
911+
outputSchema as InteropZodType<RunOutput>
912+
);
913+
return await zodParser.parse(content);
914+
} else {
915+
const jsonParser = new JsonOutputParser<RunOutput>();
916+
return await jsonParser.parse(content);
917+
}
918+
}
919+
);
858920

859921
if (!config?.includeRaw) {
860922
return llm.pipe(outputParser) as Runnable<

libs/providers/langchain-ollama/src/tests/chat_models.int.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
44
import url from "node:url";
55
import path from "node:path";
66

7+
import { z } from "zod/v3";
78
import { AIMessage, HumanMessage } from "@langchain/core/messages";
89
import { PromptTemplate } from "@langchain/core/prompts";
910
import {
@@ -210,3 +211,22 @@ test("test max tokens (numPredict)", async () => {
210211
// check for a number which is slightly above the `numPredict`.
211212
expect(numTokens).toBeLessThanOrEqual(12);
212213
});
214+
215+
test("sturctured output with tools", async () => {
216+
const ollama = new ChatOllama({
217+
maxRetries: 1,
218+
});
219+
220+
const schemaForWSO = z.object({
221+
location: z.string().describe("The city and state, e.g. San Francisco, CA"),
222+
});
223+
224+
const llmWithStructuredOutput = ollama.withStructuredOutput(schemaForWSO, {
225+
name: "get_current_weather",
226+
});
227+
228+
const resultFromWSO = await llmWithStructuredOutput.invoke(
229+
"What's the weather like today in San Francisco? Ensure you use the 'get_current_weather' tool."
230+
);
231+
expect(resultFromWSO).toEqual({ location: "San Francisco, CA" });
232+
});

0 commit comments

Comments
 (0)