Skip to content

Commit c615ed3

Browse files
committed
feat(AnthropicProvider): forced tool-use for schema-enforced structured output
Anthropic doesn't have an OpenAI-style response_format with json_schema. The equivalent is forced tool-use: declare a single tool whose input_schema matches the desired output shape, then force tool_choice to that tool. The model returns a tool_use block whose input is JSON-validated against the schema by Anthropic's own enforcement. Two changes in AnthropicProvider: 1) Request build (buildRequestPayload): when responseFormat carries _agentosUseToolForStructuredOutput: true plus a tool: { name, input_schema }, prepend that tool to payload.tools and force tool_choice: { type: 'tool', name }. Caller-provided tools survive alongside the forced one. 2) Response mapping (mapResponseToCompletion): accept an optional structuredOutputName. When set, find the matching tool_use block in the response and surface JSON.stringify(block.input) as choice.message.content. This keeps result.text semantics uniform with OpenAI's json_schema response so session.send consumers reading result.text get valid JSON regardless of provider. Existing tool-call flows (caller-provided tools, normal tool_choice, multi-step agentic loops) are unchanged; the schema mode triggers only when the marker is set on responseFormat by the buildResponseFormat adapter (Task 1).
1 parent 3ca839a commit c615ed3

1 file changed

Lines changed: 54 additions & 3 deletions

File tree

src/core/llm/providers/implementations/AnthropicProvider.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,17 @@ export class AnthropicProvider implements IProvider {
440440
payload,
441441
);
442442

443-
return this.mapResponseToCompletion(apiResponse);
443+
// Capture the structured-output tool name from the request options
444+
// so the response mapper can surface the matching tool_use block's
445+
// input as JSON-string content (uniform API across providers).
446+
const sf = options.responseFormat as
447+
| { _agentosUseToolForStructuredOutput?: boolean; tool?: { name: string } }
448+
| undefined;
449+
const structuredOutputName = sf?._agentosUseToolForStructuredOutput
450+
? sf.tool?.name
451+
: undefined;
452+
453+
return this.mapResponseToCompletion(apiResponse, structuredOutputName);
444454
}
445455

446456
/**
@@ -849,6 +859,30 @@ export class AnthropicProvider implements IProvider {
849859
}
850860
}
851861

862+
// --- Schema-driven structured output via forced tool-use ---
863+
// Anthropic doesn't have an OpenAI-style response_format with
864+
// json_schema. The equivalent is a single forced tool whose
865+
// input_schema matches the desired output shape; the model returns
866+
// a tool_use block whose input is JSON-validated by Anthropic's
867+
// own enforcement.
868+
//
869+
// The provider-format adapter (structuredOutputFormat.ts) signals
870+
// this mode by setting _agentosUseToolForStructuredOutput on the
871+
// responseFormat option. The downstream response-mapper detects the
872+
// matching block by the tool's name and surfaces its input as the
873+
// JSON-string body of the choice's message.
874+
const sf = options.responseFormat as
875+
| { _agentosUseToolForStructuredOutput?: boolean; tool?: { name: string; input_schema: Record<string, unknown> } }
876+
| undefined;
877+
if (sf?._agentosUseToolForStructuredOutput && sf.tool) {
878+
const existingTools = (payload.tools as Array<Record<string, unknown>>) ?? [];
879+
payload.tools = [
880+
{ name: sf.tool.name, input_schema: sf.tool.input_schema },
881+
...existingTools,
882+
];
883+
payload.tool_choice = { type: 'tool', name: sf.tool.name };
884+
}
885+
852886
// Pass through any custom model params
853887
if (options.customModelParams) {
854888
Object.assign(payload, options.customModelParams);
@@ -1018,12 +1052,15 @@ export class AnthropicProvider implements IProvider {
10181052
* @returns {ModelCompletionResponse} Normalized completion response.
10191053
* @private
10201054
*/
1021-
private mapResponseToCompletion(apiResponse: AnthropicMessagesResponse): ModelCompletionResponse {
1055+
private mapResponseToCompletion(
1056+
apiResponse: AnthropicMessagesResponse,
1057+
structuredOutputName?: string,
1058+
): ModelCompletionResponse {
10221059
// Collect text content
10231060
const textParts = apiResponse.content
10241061
.filter(block => block.type === 'text' && block.text)
10251062
.map(block => block.text!);
1026-
const fullText = textParts.join('');
1063+
let fullText = textParts.join('');
10271064

10281065
// Collect tool_use blocks and convert to OpenAI-style tool_calls
10291066
const toolCalls = apiResponse.content
@@ -1037,6 +1074,20 @@ export class AnthropicProvider implements IProvider {
10371074
},
10381075
}));
10391076

1077+
// Schema-driven structured output: if the request set a forced tool
1078+
// for structured output, find the matching tool_use block and surface
1079+
// its input as JSON-string content. This keeps result.text uniform
1080+
// with OpenAI's json_schema response (text is valid JSON) for
1081+
// session.send callers that consume result.text directly.
1082+
if (structuredOutputName) {
1083+
const toolBlock = apiResponse.content.find(
1084+
b => b.type === 'tool_use' && b.name === structuredOutputName,
1085+
);
1086+
if (toolBlock?.input !== undefined) {
1087+
fullText = JSON.stringify(toolBlock.input);
1088+
}
1089+
}
1090+
10401091
const hasToolCalls = toolCalls.length > 0;
10411092
const finishReason = this.mapStopReason(apiResponse.stop_reason);
10421093

0 commit comments

Comments
 (0)