Skip to content

Commit bc9a89d

Browse files
cabljacCorieWdackers86pavelgj
authored
feat(js/plugins/anthropic): add structured output support (#3881)
Co-authored-by: Corie Watson <watson.corie@gmail.com> Co-authored-by: Darren Ackers <ackers86@hotmail.com> Co-authored-by: Pavel Jbanov <pavelgj@gmail.com>
1 parent 19ef74e commit bc9a89d

File tree

20 files changed

+1683
-1005
lines changed

20 files changed

+1683
-1005
lines changed

js/plugins/anthropic/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"genkit": "workspace:^"
3030
},
3131
"dependencies": {
32-
"@anthropic-ai/sdk": "^0.68.0"
32+
"@anthropic-ai/sdk": "^0.71.2"
3333
},
3434
"devDependencies": {
3535
"@types/node": "^20.11.16",
@@ -64,6 +64,7 @@
6464
"build": "npm-run-all build:clean check compile",
6565
"build:watch": "tsup-node --watch",
6666
"test": "tsx --test tests/*_test.ts",
67+
"test:live": "tsx --test tests/live_test.ts",
6768
"test:file": "tsx --test",
6869
"test:live": "tsx --test tests/live_test.ts",
6970
"test:coverage": "check-node-version --node '>=22' && tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts"

js/plugins/anthropic/src/models.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,66 @@ export const KNOWN_CLAUDE_MODELS: Record<
9191
'claude-opus-4': commonRef('claude-opus-4', AnthropicThinkingConfigSchema),
9292
'claude-sonnet-4-5': commonRef(
9393
'claude-sonnet-4-5',
94-
AnthropicThinkingConfigSchema
94+
AnthropicThinkingConfigSchema,
95+
{
96+
supports: {
97+
multiturn: true,
98+
tools: true,
99+
media: true,
100+
systemRole: true,
101+
output: ['text', 'json'],
102+
constrained: 'all',
103+
},
104+
}
95105
),
96106
'claude-haiku-4-5': commonRef(
97107
'claude-haiku-4-5',
98-
AnthropicThinkingConfigSchema
99-
),
100-
'claude-opus-4-5': commonRef(
101-
'claude-opus-4-5',
102-
AnthropicThinkingConfigSchema
108+
AnthropicThinkingConfigSchema,
109+
{
110+
supports: {
111+
multiturn: true,
112+
tools: true,
113+
media: true,
114+
systemRole: true,
115+
output: ['text', 'json'],
116+
constrained: 'all',
117+
},
118+
}
103119
),
104120
'claude-opus-4-1': commonRef(
105121
'claude-opus-4-1',
106-
AnthropicThinkingConfigSchema
122+
AnthropicThinkingConfigSchema,
123+
{
124+
supports: {
125+
multiturn: true,
126+
tools: true,
127+
media: true,
128+
systemRole: true,
129+
output: ['text', 'json'],
130+
constrained: 'all',
131+
},
132+
}
133+
),
134+
'claude-opus-4-5': commonRef(
135+
'claude-opus-4-5',
136+
AnthropicThinkingConfigSchema.extend({
137+
output_config: z
138+
.object({
139+
effort: z.enum(['low', 'medium', 'high']).optional(),
140+
})
141+
.passthrough()
142+
.optional(),
143+
}),
144+
{
145+
supports: {
146+
multiturn: true,
147+
tools: true,
148+
media: true,
149+
systemRole: true,
150+
output: ['text', 'json'],
151+
constrained: 'all',
152+
},
153+
}
107154
),
108155
};
109156

@@ -232,9 +279,11 @@ export function claudeModel(
232279
defaultApiVersion: apiVersion,
233280
} = params;
234281
// Use supported model ref if available, otherwise create generic model ref
235-
const modelRef = KNOWN_CLAUDE_MODELS[name];
236-
const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO;
237-
const configSchema = modelRef?.configSchema ?? AnthropicConfigSchema;
282+
const knownModelRef = KNOWN_CLAUDE_MODELS[name];
283+
let modelInfo = knownModelRef
284+
? knownModelRef.info
285+
: GENERIC_CLAUDE_MODEL_INFO;
286+
const configSchema = knownModelRef?.configSchema ?? AnthropicConfigSchema;
238287

239288
return model<
240289
AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType

js/plugins/anthropic/src/runner/beta.ts

Lines changed: 160 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { logger } from 'genkit/logging';
4444

4545
import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js';
4646
import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js';
47+
import { removeUndefinedProperties } from '../utils.js';
4748
import { BaseRunner } from './base.js';
4849
import { RunnerTypes } from './types.js';
4950

@@ -66,6 +67,57 @@ const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set<string>([
6667
'container_upload',
6768
]);
6869

70+
const BETA_APIS = [
71+
// 'message-batches-2024-09-24',
72+
// 'prompt-caching-2024-07-31',
73+
// 'computer-use-2025-01-24',
74+
// 'pdfs-2024-09-25',
75+
// 'token-counting-2024-11-01',
76+
// 'token-efficient-tools-2025-02-19',
77+
// 'output-128k-2025-02-19',
78+
'files-api-2025-04-14',
79+
// 'mcp-client-2025-04-04',
80+
// 'dev-full-thinking-2025-05-14',
81+
// 'interleaved-thinking-2025-05-14',
82+
// 'code-execution-2025-05-22',
83+
// 'extended-cache-ttl-2025-04-11',
84+
// 'context-1m-2025-08-07',
85+
// 'context-management-2025-06-27',
86+
// 'model-context-window-exceeded-2025-08-26',
87+
// 'skills-2025-10-02',
88+
'effort-2025-11-24',
89+
// 'advanced-tool-use-2025-11-20',
90+
'structured-outputs-2025-11-13',
91+
];
92+
93+
/**
94+
* Transforms a JSON schema to be compatible with Anthropic's structured output requirements.
95+
* Anthropic requires `additionalProperties: false` on all object types.
96+
* @see https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs#json-schema-limitations
97+
*/
98+
function toAnthropicSchema(
99+
schema: Record<string, unknown>
100+
): Record<string, unknown> {
101+
const out = structuredClone(schema);
102+
103+
// Remove $schema if present
104+
delete out.$schema;
105+
106+
// Add additionalProperties: false to objects
107+
if (out.type === 'object') {
108+
out.additionalProperties = false;
109+
}
110+
111+
// Recursively process nested objects
112+
for (const key in out) {
113+
if (typeof out[key] === 'object' && out[key] !== null) {
114+
out[key] = toAnthropicSchema(out[key] as Record<string, unknown>);
115+
}
116+
}
117+
118+
return out;
119+
}
120+
69121
const unsupportedServerToolError = (blockType: string): string =>
70122
`Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`;
71123

@@ -140,6 +192,26 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
140192

141193
// Media
142194
if (part.media) {
195+
if (part.media.contentType === 'anthropic/file') {
196+
return {
197+
type: 'document',
198+
source: {
199+
type: 'file',
200+
file_id: part.media.url,
201+
},
202+
};
203+
}
204+
205+
if (part.media.contentType === 'anthropic/image') {
206+
return {
207+
type: 'image',
208+
source: {
209+
type: 'file',
210+
file_id: part.media.url,
211+
},
212+
};
213+
}
214+
143215
if (part.media.contentType === 'application/pdf') {
144216
return {
145217
type: 'document',
@@ -249,45 +321,49 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
249321
: system;
250322
}
251323

252-
const body: BetaMessageCreateParamsNonStreaming = {
324+
const thinkingConfig = this.toAnthropicThinkingConfig(
325+
request.config?.thinking
326+
) as BetaMessageCreateParams['thinking'] | undefined;
327+
328+
// Need to extract topP and topK from request.config to avoid duplicate properties being added to the body
329+
// This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API.
330+
// Thinking is extracted separately to avoid type issues.
331+
// ApiVersion is extracted separately as it's not a valid property for the Anthropic API.
332+
const {
333+
topP,
334+
topK,
335+
apiVersion: _1,
336+
thinking: _2,
337+
...restConfig
338+
} = request.config ?? {};
339+
340+
const body = {
253341
model: mappedModelName,
254342
max_tokens:
255343
request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS,
256344
messages,
257-
};
258-
259-
if (betaSystem !== undefined) body.system = betaSystem;
260-
if (request.config?.stopSequences !== undefined)
261-
body.stop_sequences = request.config.stopSequences;
262-
if (request.config?.temperature !== undefined)
263-
body.temperature = request.config.temperature;
264-
if (request.config?.topK !== undefined) body.top_k = request.config.topK;
265-
if (request.config?.topP !== undefined) body.top_p = request.config.topP;
266-
if (request.config?.tool_choice !== undefined) {
267-
body.tool_choice = request.config
268-
.tool_choice as BetaMessageCreateParams['tool_choice'];
269-
}
270-
if (request.config?.metadata !== undefined) {
271-
body.metadata = request.config
272-
.metadata as BetaMessageCreateParams['metadata'];
273-
}
274-
if (request.tools) {
275-
body.tools = request.tools.map((tool) => this.toAnthropicTool(tool));
276-
}
277-
const thinkingConfig = this.toAnthropicThinkingConfig(
278-
request.config?.thinking
279-
);
280-
if (thinkingConfig) {
281-
body.thinking = thinkingConfig as BetaMessageCreateParams['thinking'];
282-
}
283-
284-
if (request.output?.format && request.output.format !== 'text') {
285-
throw new Error(
286-
`Only text output format is supported for Claude models currently`
287-
);
288-
}
289-
290-
return body;
345+
system: betaSystem,
346+
stop_sequences: request.config?.stopSequences,
347+
temperature: request.config?.temperature,
348+
top_k: topK,
349+
top_p: topP,
350+
tool_choice: request.config?.tool_choice,
351+
metadata: request.config?.metadata,
352+
tools: request.tools?.map((tool) => this.toAnthropicTool(tool)),
353+
thinking: thinkingConfig,
354+
output_format: this.isStructuredOutputEnabled(request)
355+
? {
356+
type: 'json_schema',
357+
schema: toAnthropicSchema(request.output!.schema!),
358+
}
359+
: undefined,
360+
betas: Array.isArray(request.config?.betas)
361+
? [...(request.config?.betas ?? [])]
362+
: [...BETA_APIS],
363+
...restConfig,
364+
} as BetaMessageCreateParamsNonStreaming;
365+
366+
return removeUndefinedProperties(body);
291367
}
292368

293369
/**
@@ -316,46 +392,50 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
316392
]
317393
: system;
318394

319-
const body: BetaMessageCreateParamsStreaming = {
395+
const thinkingConfig = this.toAnthropicThinkingConfig(
396+
request.config?.thinking
397+
) as BetaMessageCreateParams['thinking'] | undefined;
398+
399+
// Need to extract topP and topK from request.config to avoid duplicate properties being added to the body
400+
// This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API.
401+
// Thinking is extracted separately to avoid type issues.
402+
// ApiVersion is extracted separately as it's not a valid property for the Anthropic API.
403+
const {
404+
topP,
405+
topK,
406+
apiVersion: _1,
407+
thinking: _2,
408+
...restConfig
409+
} = request.config ?? {};
410+
411+
const body = {
320412
model: mappedModelName,
321413
max_tokens:
322414
request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS,
323415
messages,
324416
stream: true,
325-
};
326-
327-
if (betaSystem !== undefined) body.system = betaSystem;
328-
if (request.config?.stopSequences !== undefined)
329-
body.stop_sequences = request.config.stopSequences;
330-
if (request.config?.temperature !== undefined)
331-
body.temperature = request.config.temperature;
332-
if (request.config?.topK !== undefined) body.top_k = request.config.topK;
333-
if (request.config?.topP !== undefined) body.top_p = request.config.topP;
334-
if (request.config?.tool_choice !== undefined) {
335-
body.tool_choice = request.config
336-
.tool_choice as BetaMessageCreateParams['tool_choice'];
337-
}
338-
if (request.config?.metadata !== undefined) {
339-
body.metadata = request.config
340-
.metadata as BetaMessageCreateParams['metadata'];
341-
}
342-
if (request.tools) {
343-
body.tools = request.tools.map((tool) => this.toAnthropicTool(tool));
344-
}
345-
const thinkingConfig = this.toAnthropicThinkingConfig(
346-
request.config?.thinking
347-
);
348-
if (thinkingConfig) {
349-
body.thinking = thinkingConfig as BetaMessageCreateParams['thinking'];
350-
}
351-
352-
if (request.output?.format && request.output.format !== 'text') {
353-
throw new Error(
354-
`Only text output format is supported for Claude models currently`
355-
);
356-
}
357-
358-
return body;
417+
system: betaSystem,
418+
stop_sequences: request.config?.stopSequences,
419+
temperature: request.config?.temperature,
420+
top_k: topK,
421+
top_p: topP,
422+
tool_choice: request.config?.tool_choice,
423+
metadata: request.config?.metadata,
424+
tools: request.tools?.map((tool) => this.toAnthropicTool(tool)),
425+
thinking: thinkingConfig,
426+
output_format: this.isStructuredOutputEnabled(request)
427+
? {
428+
type: 'json_schema',
429+
schema: toAnthropicSchema(request.output!.schema!),
430+
}
431+
: undefined,
432+
betas: Array.isArray(request.config?.betas)
433+
? [...(request.config?.betas ?? [])]
434+
: [...BETA_APIS],
435+
...restConfig,
436+
} as BetaMessageCreateParamsStreaming;
437+
438+
return removeUndefinedProperties(body);
359439
}
360440

361441
protected toGenkitResponse(message: BetaMessage): GenerateResponseData {
@@ -491,4 +571,14 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
491571
return 'other';
492572
}
493573
}
574+
575+
private isStructuredOutputEnabled(
576+
request: GenerateRequest<typeof AnthropicConfigSchema>
577+
): boolean {
578+
return !!(
579+
request.output?.schema &&
580+
request.output.constrained &&
581+
request.output.format === 'json'
582+
);
583+
}
494584
}

0 commit comments

Comments
 (0)