Skip to content
Draft
3 changes: 2 additions & 1 deletion js/plugins/anthropic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"genkit": "workspace:^"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.68.0"
"@anthropic-ai/sdk": "^0.71.2"
},
"devDependencies": {
"@types/node": "^20.11.16",
Expand Down Expand Up @@ -64,6 +64,7 @@
"build": "npm-run-all build:clean check compile",
"build:watch": "tsup-node --watch",
"test": "tsx --test tests/*_test.ts",
"test:live": "tsx --test tests/live_test.ts",
"test:file": "tsx --test",
"test:coverage": "check-node-version --node '>=22' && tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts"
}
Expand Down
44 changes: 38 additions & 6 deletions js/plugins/anthropic/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,45 @@ export const KNOWN_CLAUDE_MODELS: Record<
'claude-opus-4': commonRef('claude-opus-4', AnthropicThinkingConfigSchema),
'claude-sonnet-4-5': commonRef(
'claude-sonnet-4-5',
AnthropicThinkingConfigSchema
AnthropicThinkingConfigSchema,
{
supports: {
multiturn: true,
tools: true,
media: true,
systemRole: true,
output: ['text', 'json'],
constrained: 'all',
},
}
),
'claude-haiku-4-5': commonRef(
'claude-haiku-4-5',
AnthropicThinkingConfigSchema
AnthropicThinkingConfigSchema,
{
supports: {
multiturn: true,
tools: true,
media: true,
systemRole: true,
output: ['text', 'json'],
constrained: 'all',
},
}
),
'claude-opus-4-1': commonRef(
'claude-opus-4-1',
AnthropicThinkingConfigSchema
AnthropicThinkingConfigSchema,
{
supports: {
multiturn: true,
tools: true,
media: true,
systemRole: true,
output: ['text', 'json'],
constrained: 'all',
},
}
),
};

Expand Down Expand Up @@ -216,9 +246,11 @@ export function claudeModel(
defaultApiVersion: apiVersion,
} = params;
// Use supported model ref if available, otherwise create generic model ref
const modelRef = KNOWN_CLAUDE_MODELS[name];
const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO;
const configSchema = modelRef?.configSchema ?? AnthropicConfigSchema;
const knownModelRef = KNOWN_CLAUDE_MODELS[name];
let modelInfo = knownModelRef
? knownModelRef.info
: GENERIC_CLAUDE_MODEL_INFO;
const configSchema = knownModelRef?.configSchema ?? AnthropicConfigSchema;

return model<
AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType
Expand Down
83 changes: 74 additions & 9 deletions js/plugins/anthropic/src/runner/beta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set<string>([
'container_upload',
]);

const BETA_APIS = [
// 'message-batches-2024-09-24',
// 'prompt-caching-2024-07-31',
// 'computer-use-2025-01-24',
// 'pdfs-2024-09-25',
// 'token-counting-2024-11-01',
// 'token-efficient-tools-2025-02-19',
// 'output-128k-2025-02-19',
// 'files-api-2025-04-14',
// 'mcp-client-2025-04-04',
// 'dev-full-thinking-2025-05-14',
// 'interleaved-thinking-2025-05-14',
// 'code-execution-2025-05-22',
// 'extended-cache-ttl-2025-04-11',
// 'context-1m-2025-08-07',
// 'context-management-2025-06-27',
// 'model-context-window-exceeded-2025-08-26',
// 'skills-2025-10-02',
// 'effort-param-2025-11-24',
// 'advanced-tool-use-2025-11-20',
'structured-outputs-2025-11-13',
];

/**
* Transforms a JSON schema to be compatible with Anthropic's structured output requirements.
* Anthropic requires `additionalProperties: false` on all object types.
*/
function toAnthropicSchema(
schema: Record<string, unknown>
): Record<string, unknown> {
const out = structuredClone(schema);

// Remove $schema if present
delete out.$schema;

// Add additionalProperties: false to objects
if (out.type === 'object') {
out.additionalProperties = false;
}

// Recursively process nested objects
for (const key in out) {
if (typeof out[key] === 'object' && out[key] !== null) {
out[key] = toAnthropicSchema(out[key] as Record<string, unknown>);
}
}

return out;
}

const unsupportedServerToolError = (blockType: string): string =>
`Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`;

Expand Down Expand Up @@ -254,6 +304,7 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
max_tokens:
request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS,
messages,
betas: BETA_APIS,
};

if (betaSystem !== undefined) body.system = betaSystem;
Expand Down Expand Up @@ -281,10 +332,12 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
body.thinking = thinkingConfig as BetaMessageCreateParams['thinking'];
}

if (request.output?.format && request.output.format !== 'text') {
throw new Error(
`Only text output format is supported for Claude models currently`
);
// Apply structured output when model supports it and constrained output is requested
if (this.isStructuredOutputEnabled(request)) {
body.output_format = {
type: 'json_schema',
schema: toAnthropicSchema(request.output!.schema!),
};
}

return body;
Expand Down Expand Up @@ -322,6 +375,7 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS,
messages,
stream: true,
betas: BETA_APIS,
};

if (betaSystem !== undefined) body.system = betaSystem;
Expand Down Expand Up @@ -349,12 +403,13 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
body.thinking = thinkingConfig as BetaMessageCreateParams['thinking'];
}

if (request.output?.format && request.output.format !== 'text') {
throw new Error(
`Only text output format is supported for Claude models currently`
);
// Apply structured output when model supports it and constrained output is requested
if (this.isStructuredOutputEnabled(request)) {
body.output_format = {
type: 'json_schema',
schema: toAnthropicSchema(request.output!.schema!),
};
}

return body;
}

Expand Down Expand Up @@ -491,4 +546,14 @@ export class BetaRunner extends BaseRunner<BetaRunnerTypes> {
return 'other';
}
}

private isStructuredOutputEnabled(
request: GenerateRequest<typeof AnthropicConfigSchema>
): boolean {
return !!(
request.output?.schema &&
request.output.constrained &&
request.output.format === 'json'
);
}
}
70 changes: 70 additions & 0 deletions js/plugins/anthropic/tests/live_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as assert from 'assert';
import { genkit, z } from 'genkit';
import { describe, it } from 'node:test';
import { anthropic } from '../src/index.js';

const SKIP_LIVE_TESTS = !process.env.ANTHROPIC_API_KEY;

describe('Anthropic Live Tests', { skip: SKIP_LIVE_TESTS }, () => {
it('should return structured output matching the schema', async () => {
const ai = genkit({
plugins: [anthropic({ apiVersion: 'beta' })],
});

const schema = z.object({
name: z.string(),
age: z.number(),
city: z.string(),
isStudent: z.boolean(),
isEmployee: z.boolean(),
isRetired: z.boolean(),
isUnemployed: z.boolean(),
isDisabled: z.boolean(),
});

const result = await ai.generate({
model: 'anthropic/claude-sonnet-4-5',
prompt:
'Generate a fictional person with name "Alice", age 30, and city "New York". Return only the JSON.',
output: { schema, format: 'json', constrained: true },
});

const parsed = result.output;
assert.ok(parsed, 'Should have parsed output');
assert.deepStrictEqual(
{ name: parsed.name, age: parsed.age, city: parsed.city },
{ name: 'Alice', age: 30, city: 'New York' }
);

// Check that boolean fields are present and are actually booleans
for (const key of [
'isStudent',
'isEmployee',
'isRetired',
'isUnemployed',
'isDisabled',
]) {
assert.strictEqual(
typeof parsed[key],
'boolean',
`Field ${key} should be a boolean but got: ${typeof parsed[key]}`
);
}
});
});
1 change: 1 addition & 0 deletions js/plugins/anthropic/tests/mocks/anthropic-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ export function mockMessageWithContent(
}

function toBetaMessage(message: Message): BetaMessage {
// @ts-ignore
return {
...message,
container: null,
Expand Down
Loading