Skip to content

Commit c8e3e54

Browse files
feat(langchain): use model profiles to for structured output in createAgent
1 parent f17b2c9 commit c8e3e54

File tree

7 files changed

+115
-165
lines changed

7 files changed

+115
-165
lines changed

‎libs/langchain/src/agents/model.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import type { LanguageModelLike } from "@langchain/core/language_models/base";
22
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
33

4-
export interface ConfigurableModelInterface {
5-
_queuedMethodOperations: Record<string, unknown>;
6-
_model: () => Promise<BaseChatModel>;
7-
}
4+
import type { ConfigurableModel } from "../chat_models/universal.js";
85

96
export function isBaseChatModel(
107
model: LanguageModelLike
@@ -18,7 +15,7 @@ export function isBaseChatModel(
1815

1916
export function isConfigurableModel(
2017
model: unknown
21-
): model is ConfigurableModelInterface {
18+
): model is ConfigurableModel {
2219
return (
2320
typeof model === "object" &&
2421
model != null &&

‎libs/langchain/src/agents/nodes/AgentNode.ts‎

Lines changed: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { Runnable, RunnableConfig } from "@langchain/core/runnables";
33
import { BaseMessage, AIMessage, ToolMessage } from "@langchain/core/messages";
44
import { Command, type LangGraphRunnableConfig } from "@langchain/langgraph";
55
import { type LanguageModelLike } from "@langchain/core/language_models/base";
6-
import { type BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
6+
import {
7+
type BaseChatModel,
8+
type BaseChatModelCallOptions,
9+
} from "@langchain/core/language_models/chat_models";
710
import {
811
InteropZodObject,
9-
getSchemaDescription,
1012
interopParse,
1113
interopZodObjectPartial,
1214
} from "@langchain/core/utils/types";
@@ -133,16 +135,15 @@ export class AgentNode<
133135
* @param model - The model to get the response format for.
134136
* @returns The response format.
135137
*/
136-
#getResponseFormat(
138+
async #getResponseFormat(
137139
model: string | LanguageModelLike
138-
): ResponseFormat | undefined {
140+
): Promise<ResponseFormat | undefined> {
139141
if (!this.#options.responseFormat) {
140142
return undefined;
141143
}
142144

143-
const strategies = transformResponseFormat(
145+
const strategies = await transformResponseFormat(
144146
this.#options.responseFormat,
145-
undefined,
146147
model
147148
);
148149

@@ -278,9 +279,11 @@ export class AgentNode<
278279
*/
279280
validateLLMHasNoBoundTools(request.model);
280281

281-
const structuredResponseFormat = this.#getResponseFormat(request.model);
282+
const structuredResponseFormat = await this.#getResponseFormat(
283+
request.model
284+
);
282285
const modelWithTools = await this.#bindTools(
283-
request.model,
286+
request.model as BaseChatModel,
284287
request,
285288
structuredResponseFormat
286289
);
@@ -293,20 +296,21 @@ export class AgentNode<
293296
const response = (await modelWithTools.invoke(
294297
modelInput,
295298
invokeConfig
296-
)) as AIMessage;
299+
)) as AIMessage | { raw: BaseMessage; parsed: StructuredResponseFormat };
297300

298301
/**
299-
* if the user requests a native schema output, try to parse the response
300-
* and return the structured response if it is valid
302+
* if the user requests a native schema output, we should receive a raw message
303+
* with the structured response
301304
*/
302-
if (structuredResponseFormat?.type === "native") {
303-
const structuredResponse =
304-
structuredResponseFormat.strategy.parse(response);
305-
if (structuredResponse) {
306-
return { structuredResponse, messages: [response] };
305+
if (structuredResponseFormat?.type === "native" || "raw" in response) {
306+
if (!("raw" in response)) {
307+
throw new Error("Response is not a structured response.");
307308
}
308309

309-
return response;
310+
return {
311+
structuredResponse: response.parsed,
312+
messages: [response.raw],
313+
};
310314
}
311315

312316
if (!structuredResponseFormat || !response.tool_calls) {
@@ -731,7 +735,7 @@ export class AgentNode<
731735
}
732736

733737
async #bindTools(
734-
model: LanguageModelLike,
738+
model: BaseChatModel,
735739
preparedOptions: ModelRequest | undefined,
736740
structuredResponseFormat: ResponseFormat | undefined
737741
): Promise<Runnable> {
@@ -761,37 +765,26 @@ export class AgentNode<
761765
/**
762766
* check if the user requests a native schema output
763767
*/
764-
if (structuredResponseFormat?.type === "native") {
765-
const jsonSchemaParams = {
766-
name: structuredResponseFormat.strategy.schema?.name ?? "extract",
767-
description: getSchemaDescription(
768-
structuredResponseFormat.strategy.schema
769-
),
770-
schema: structuredResponseFormat.strategy.schema,
771-
strict: true,
772-
};
773-
774-
Object.assign(options, {
775-
response_format: {
776-
type: "json_schema",
777-
json_schema: jsonSchemaParams,
778-
},
779-
ls_structured_output_format: {
780-
kwargs: { method: "json_schema" },
781-
schema: structuredResponseFormat.strategy.schema,
782-
},
783-
strict: true,
784-
});
785-
}
768+
const modelWithStructuredOutput =
769+
structuredResponseFormat?.type === "native"
770+
? model.withStructuredOutput(structuredResponseFormat.strategy.schema, {
771+
includeRaw: true,
772+
method: "jsonSchema",
773+
})
774+
: model;
786775

787776
/**
788777
* Bind tools to the model if they are not already bound.
789778
*/
790-
const modelWithTools = await bindTools(model, allTools, {
791-
...options,
792-
...(preparedOptions?.modelSettings ?? {}),
793-
tool_choice: toolChoice,
794-
});
779+
const modelWithTools = await bindTools(
780+
modelWithStructuredOutput as LanguageModelLike,
781+
allTools,
782+
{
783+
...options,
784+
...(preparedOptions?.modelSettings ?? {}),
785+
tool_choice: toolChoice,
786+
}
787+
);
795788

796789
/**
797790
* Create a model runnable with the prompt and agent name

‎libs/langchain/src/agents/responses.ts‎

Lines changed: 38 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import { type AIMessage } from "@langchain/core/messages";
1010
import { type LanguageModelLike } from "@langchain/core/language_models/base";
1111
import { toJsonSchema, Validator } from "@langchain/core/utils/json_schema";
1212
import { type FunctionDefinition } from "@langchain/core/language_models/base";
13+
import { type BaseChatModel } from "@langchain/core/language_models/chat_models";
1314

15+
import { initChatModel } from "../chat_models/universal.js";
1416
import {
1517
StructuredOutputParsingError,
1618
MultipleStructuredOutputsError,
1719
} from "./errors.js";
18-
import { isConfigurableModel, isBaseChatModel } from "./model.js";
20+
import { isConfigurableModel } from "./model.js";
1921

2022
/**
2123
* Special type to indicate that no response format is provided.
@@ -206,7 +208,7 @@ export type ResponseFormat = ToolStrategy<any> | ProviderStrategy<any>;
206208
* @param model - The model to check if it supports JSON schema output
207209
* @returns
208210
*/
209-
export function transformResponseFormat(
211+
export async function transformResponseFormat(
210212
responseFormat?:
211213
| InteropZodType<any>
212214
| InteropZodType<any>[]
@@ -215,9 +217,8 @@ export function transformResponseFormat(
215217
| ResponseFormat
216218
| ToolStrategy<any>[]
217219
| ResponseFormatUndefined,
218-
options?: ToolStrategyOptions,
219220
model?: LanguageModelLike | string
220-
): ResponseFormat[] {
221+
): Promise<ResponseFormat[]> {
221222
if (!responseFormat) {
222223
return [];
223224
}
@@ -237,62 +238,49 @@ export function transformResponseFormat(
237238
*/
238239
if (Array.isArray(responseFormat)) {
239240
/**
240-
* if every entry is a ToolStrategy or ProviderStrategy instance, return the array as is
241+
* we don't allow to have a list of ProviderStrategy instances
241242
*/
242-
if (
243-
responseFormat.every(
244-
(item) =>
245-
item instanceof ToolStrategy || item instanceof ProviderStrategy
246-
)
247-
) {
248-
return responseFormat as unknown as ResponseFormat[];
249-
}
250-
251-
/**
252-
* Check if all items are Zod schemas
253-
*/
254-
if (responseFormat.every((item) => isInteropZodObject(item))) {
255-
return responseFormat.map((item) =>
256-
ToolStrategy.fromSchema(item as InteropZodObject, options)
243+
if (responseFormat.some((item) => item instanceof ProviderStrategy)) {
244+
throw new Error(
245+
"Invalid response format: list contains ProviderStrategy instances. You can only use a single ProviderStrategy instance instead."
257246
);
258247
}
259248

260249
/**
261-
* Check if all items are plain objects (JSON schema)
250+
* if every entry is a ToolStrategy or ProviderStrategy instance, return the array as is
262251
*/
263-
if (
264-
responseFormat.every(
265-
(item) =>
266-
typeof item === "object" && item !== null && !isInteropZodObject(item)
267-
)
268-
) {
269-
return responseFormat.map((item) =>
270-
ToolStrategy.fromSchema(item as JsonSchemaFormat, options)
271-
);
252+
if (responseFormat.every((item) => item instanceof ToolStrategy)) {
253+
return responseFormat as ResponseFormat[];
272254
}
273255

274256
throw new Error(
275-
`Invalid response format: list contains mixed types.\n` +
257+
`Invalid response format: list contains invalid values.\n` +
276258
`All items must be either InteropZodObject or plain JSON schema objects.`
277259
);
278260
}
279261

262+
/**
263+
* if the response format is a ToolStrategy or ProviderStrategy instance, return it as is
264+
*/
280265
if (
281266
responseFormat instanceof ToolStrategy ||
282267
responseFormat instanceof ProviderStrategy
283268
) {
284269
return [responseFormat];
285270
}
286271

287-
const useProviderStrategy = hasSupportForJsonSchemaOutput(model);
272+
/**
273+
* If nothing is specified we have to check whether the model supports JSON schema output
274+
*/
275+
const useProviderStrategy = await hasSupportForJsonSchemaOutput(model);
288276

289277
/**
290278
* `responseFormat` is a Zod schema
291279
*/
292280
if (isInteropZodObject(responseFormat)) {
293281
return useProviderStrategy
294282
? [ProviderStrategy.fromSchema(responseFormat)]
295-
: [ToolStrategy.fromSchema(responseFormat, options)];
283+
: [ToolStrategy.fromSchema(responseFormat)];
296284
}
297285

298286
/**
@@ -305,7 +293,7 @@ export function transformResponseFormat(
305293
) {
306294
return useProviderStrategy
307295
? [ProviderStrategy.fromSchema(responseFormat as JsonSchemaFormat)]
308-
: [ToolStrategy.fromSchema(responseFormat as JsonSchemaFormat, options)];
296+
: [ToolStrategy.fromSchema(responseFormat as JsonSchemaFormat)];
309297
}
310298

311299
throw new Error(`Invalid response format: ${String(responseFormat)}`);
@@ -380,7 +368,12 @@ export function toolStrategy(
380368
| JsonSchemaFormat[],
381369
options?: ToolStrategyOptions
382370
): TypedToolStrategy {
383-
return transformResponseFormat(responseFormat, options) as TypedToolStrategy;
371+
const responseFormatArray = Array.isArray(responseFormat)
372+
? responseFormat
373+
: [responseFormat];
374+
return responseFormatArray.map((item) =>
375+
ToolStrategy.fromSchema(item as InteropZodObject, options)
376+
) as TypedToolStrategy;
384377
}
385378

386379
export function providerStrategy<T extends InteropZodType<any>>(
@@ -419,76 +412,27 @@ export type JsonSchemaFormat = {
419412
__brand?: never;
420413
};
421414

422-
const CHAT_MODELS_THAT_SUPPORT_JSON_SCHEMA_OUTPUT = ["ChatOpenAI", "ChatXAI"];
423-
const MODEL_NAMES_THAT_SUPPORT_JSON_SCHEMA_OUTPUT = [
424-
"grok",
425-
"gpt-5",
426-
"gpt-4.1",
427-
"gpt-4o",
428-
"gpt-oss",
429-
"o3-pro",
430-
"o3-mini",
431-
];
432-
433415
/**
434416
* Identifies the models that support JSON schema output
435417
* @param model - The model to check
436418
* @returns True if the model supports JSON schema output, false otherwise
437419
*/
438-
export function hasSupportForJsonSchemaOutput(
420+
export async function hasSupportForJsonSchemaOutput(
439421
model?: LanguageModelLike | string
440-
): boolean {
422+
): Promise<boolean> {
441423
if (!model) {
442424
return false;
443425
}
444426

445-
if (typeof model === "string") {
446-
const modelName = model.split(":").pop() as string;
447-
return MODEL_NAMES_THAT_SUPPORT_JSON_SCHEMA_OUTPUT.some(
448-
(modelNameSnippet) => modelName.includes(modelNameSnippet)
449-
);
450-
}
451-
452-
if (isConfigurableModel(model)) {
453-
const configurableModel = model as unknown as {
454-
_defaultConfig: { model: string };
455-
};
456-
return hasSupportForJsonSchemaOutput(
457-
configurableModel._defaultConfig.model
458-
);
459-
}
460-
461-
if (!isBaseChatModel(model)) {
462-
return false;
463-
}
427+
const resolvedModel =
428+
typeof model === "string"
429+
? await initChatModel(model)
430+
: (model as BaseChatModel);
464431

465-
const chatModelClass = model.getName();
466-
467-
/**
468-
* for testing purposes only
469-
*/
470-
if (chatModelClass === "FakeToolCallingChatModel") {
471-
return true;
472-
}
473-
474-
if (
475-
CHAT_MODELS_THAT_SUPPORT_JSON_SCHEMA_OUTPUT.includes(chatModelClass) &&
476-
/**
477-
* OpenAI models
478-
*/ (("model" in model &&
479-
MODEL_NAMES_THAT_SUPPORT_JSON_SCHEMA_OUTPUT.some(
480-
(modelNameSnippet) =>
481-
typeof model.model === "string" &&
482-
model.model.includes(modelNameSnippet)
483-
)) ||
484-
/**
485-
* for testing purposes only
486-
*/
487-
(chatModelClass === "FakeToolCallingModel" &&
488-
"structuredResponse" in model))
489-
) {
490-
return true;
432+
if (isConfigurableModel(resolvedModel)) {
433+
const profile = await resolvedModel._getProfile();
434+
return profile.structuredOutput ?? false;
491435
}
492436

493-
return false;
437+
return resolvedModel.profile.structuredOutput ?? false;
494438
}

0 commit comments

Comments
 (0)