diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 9fd7a306a1..56fae83b9a 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -44,6 +44,7 @@ import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; +import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; import { RunnerTypes } from './types.js'; @@ -299,48 +300,49 @@ export class BetaRunner extends BaseRunner { : system; } + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ) as BetaMessageCreateParams['thinking'] | undefined; + + // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body + // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. + // Thinking is extracted separately to avoid type issues. + // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + const { + topP, + topK, + apiVersion: _1, + thinking: _2, + ...restConfig + } = request.config ?? {}; + const body: BetaMessageCreateParamsNonStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, messages, - betas: BETA_APIS, + system: betaSystem, + stop_sequences: request.config?.stopSequences, + temperature: request.config?.temperature, + top_k: topK, + top_p: topP, + tool_choice: request.config?.tool_choice, + metadata: request.config?.metadata, + tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + thinking: thinkingConfig, + output_format: this.isStructuredOutputEnabled(request) + ? { + type: 'json_schema', + schema: toAnthropicSchema(request.output!.schema!), + } + : undefined, + betas: Array.isArray(request.config?.betas) + ? [...(request.config?.betas ?? [])] + : [...BETA_APIS], + ...restConfig, }; - if (betaSystem !== undefined) body.system = betaSystem; - if (request.config?.stopSequences !== undefined) - body.stop_sequences = request.config.stopSequences; - if (request.config?.temperature !== undefined) - body.temperature = request.config.temperature; - if (request.config?.topK !== undefined) body.top_k = request.config.topK; - if (request.config?.topP !== undefined) body.top_p = request.config.topP; - if (request.config?.tool_choice !== undefined) { - body.tool_choice = request.config - .tool_choice as BetaMessageCreateParams['tool_choice']; - } - if (request.config?.metadata !== undefined) { - body.metadata = request.config - .metadata as BetaMessageCreateParams['metadata']; - } - if (request.tools) { - body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); - } - const thinkingConfig = this.toAnthropicThinkingConfig( - request.config?.thinking - ); - if (thinkingConfig) { - body.thinking = thinkingConfig as BetaMessageCreateParams['thinking']; - } - - // 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; + return removeUndefinedProperties(body); } /** @@ -369,48 +371,50 @@ export class BetaRunner extends BaseRunner { ] : system; + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ) as BetaMessageCreateParams['thinking'] | undefined; + + // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body + // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. + // Thinking is extracted separately to avoid type issues. + // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + const { + topP, + topK, + apiVersion: _1, + thinking: _2, + ...restConfig + } = request.config ?? {}; + const body: BetaMessageCreateParamsStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, messages, stream: true, - betas: BETA_APIS, + system: betaSystem, + stop_sequences: request.config?.stopSequences, + temperature: request.config?.temperature, + top_k: topK, + top_p: topP, + tool_choice: request.config?.tool_choice, + metadata: request.config?.metadata, + tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + thinking: thinkingConfig, + output_format: this.isStructuredOutputEnabled(request) + ? { + type: 'json_schema', + schema: toAnthropicSchema(request.output!.schema!), + } + : undefined, + betas: Array.isArray(request.config?.betas) + ? [...(request.config?.betas ?? [])] + : [...BETA_APIS], + ...restConfig, }; - if (betaSystem !== undefined) body.system = betaSystem; - if (request.config?.stopSequences !== undefined) - body.stop_sequences = request.config.stopSequences; - if (request.config?.temperature !== undefined) - body.temperature = request.config.temperature; - if (request.config?.topK !== undefined) body.top_k = request.config.topK; - if (request.config?.topP !== undefined) body.top_p = request.config.topP; - if (request.config?.tool_choice !== undefined) { - body.tool_choice = request.config - .tool_choice as BetaMessageCreateParams['tool_choice']; - } - if (request.config?.metadata !== undefined) { - body.metadata = request.config - .metadata as BetaMessageCreateParams['metadata']; - } - if (request.tools) { - body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); - } - const thinkingConfig = this.toAnthropicThinkingConfig( - request.config?.thinking - ); - if (thinkingConfig) { - body.thinking = thinkingConfig as BetaMessageCreateParams['thinking']; - } - - // 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; + return removeUndefinedProperties(body); } protected toGenkitResponse(message: BetaMessage): GenerateResponseData { diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 0c8f7ffc4f..1496029ebd 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -42,8 +42,10 @@ import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; +import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; + interface RunnerTypes extends BaseRunnerTypes { Message: Message; Stream: MessageStream; @@ -179,6 +181,12 @@ export class Runner extends BaseRunner { request: GenerateRequest, cacheSystemPrompt?: boolean ): MessageCreateParamsNonStreaming { + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = @@ -197,51 +205,40 @@ export class Runner extends BaseRunner { ] : system; + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ) as MessageCreateParams['thinking'] | undefined; + + // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body + // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. + // Thinking is extracted separately to avoid type issues. + // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + const { + topP, + topK, + apiVersion: _1, + thinking: _2, + ...restConfig + } = request.config ?? {}; + const body: MessageCreateParamsNonStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, messages, + system: systemValue, + stop_sequences: request.config?.stopSequences, + temperature: request.config?.temperature, + top_k: topK, + top_p: topP, + tool_choice: request.config?.tool_choice, + metadata: request.config?.metadata, + tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + thinking: thinkingConfig, + ...restConfig, }; - if (systemValue !== undefined) { - body.system = systemValue; - } - - if (request.tools) { - body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); - } - if (request.config?.topK !== undefined) { - body.top_k = request.config.topK; - } - if (request.config?.topP !== undefined) { - body.top_p = request.config.topP; - } - if (request.config?.temperature !== undefined) { - body.temperature = request.config.temperature; - } - if (request.config?.stopSequences !== undefined) { - body.stop_sequences = request.config.stopSequences; - } - if (request.config?.metadata !== undefined) { - body.metadata = request.config.metadata; - } - if (request.config?.tool_choice !== undefined) { - body.tool_choice = request.config.tool_choice; - } - const thinkingConfig = this.toAnthropicThinkingConfig( - request.config?.thinking - ); - if (thinkingConfig) { - body.thinking = thinkingConfig as MessageCreateParams['thinking']; - } - - if (request.output?.format && request.output.format !== 'text') { - throw new Error( - `Only text output format is supported for Claude models currently` - ); - } - return body; + return removeUndefinedProperties(body); } protected toAnthropicStreamingRequestBody( @@ -249,6 +246,12 @@ export class Runner extends BaseRunner { request: GenerateRequest, cacheSystemPrompt?: boolean ): MessageCreateParamsStreaming { + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = @@ -267,53 +270,41 @@ export class Runner extends BaseRunner { ] : system; + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ) as MessageCreateParams['thinking'] | undefined; + + // Need to extract topP and topK from request.config to avoid duplicate properties being added to the body + // This happens because topP and topK have different property names (top_p and top_k) in the Anthropic API. + // Thinking is extracted separately to avoid type issues. + // ApiVersion is extracted separately as it's not a valid property for the Anthropic API. + const { + topP, + topK, + apiVersion: _1, + thinking: _2, + ...restConfig + } = request.config ?? {}; + const body: MessageCreateParamsStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, messages, stream: true, + system: systemValue, + stop_sequences: request.config?.stopSequences, + temperature: request.config?.temperature, + top_k: topK, + top_p: topP, + tool_choice: request.config?.tool_choice, + metadata: request.config?.metadata, + tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), + thinking: thinkingConfig, + ...restConfig, }; - if (systemValue !== undefined) { - body.system = systemValue; - } - - if (request.tools) { - body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); - } - if (request.config?.topK !== undefined) { - body.top_k = request.config.topK; - } - if (request.config?.topP !== undefined) { - body.top_p = request.config.topP; - } - if (request.config?.temperature !== undefined) { - body.temperature = request.config.temperature; - } - if (request.config?.stopSequences !== undefined) { - body.stop_sequences = request.config.stopSequences; - } - if (request.config?.metadata !== undefined) { - body.metadata = request.config.metadata; - } - if (request.config?.tool_choice !== undefined) { - body.tool_choice = request.config.tool_choice; - } - const thinkingConfig = this.toAnthropicThinkingConfig( - request.config?.thinking - ); - if (thinkingConfig) { - body.thinking = - thinkingConfig as MessageCreateParamsStreaming['thinking']; - } - - if (request.output?.format && request.output.format !== 'text') { - throw new Error( - `Only text output format is supported for Claude models currently` - ); - } - return body; + return removeUndefinedProperties(body); } protected async createMessage( diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 9796d71c36..5947bc1a52 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -67,26 +67,42 @@ export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} export const AnthropicBaseConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ - z.object({ - type: z.literal('auto'), - }), - z.object({ - type: z.literal('any'), - }), - z.object({ - type: z.literal('tool'), - name: z.string(), - }), + z + .object({ + type: z.literal('auto'), + }) + .passthrough(), + z + .object({ + type: z.literal('any'), + }) + .passthrough(), + z + .object({ + type: z.literal('tool'), + name: z.string(), + }) + .passthrough(), ]) + .describe( + 'The tool choice to use for the request. This can be used to specify the tool to use for the request. If not specified, the model will choose the tool to use.' + ) .optional(), metadata: z .object({ user_id: z.string().optional(), }) + .describe('The metadata to include in the request.') + .passthrough() .optional(), /** Optional shorthand to pick API surface for this request. */ - apiVersion: z.enum(['stable', 'beta']).optional(), -}); + apiVersion: z + .enum(['stable', 'beta']) + .optional() + .describe( + 'The API version to use for the request. Both stable and beta features are available on the beta API surface.' + ), +}).passthrough(); export type AnthropicBaseConfigSchemaType = typeof AnthropicBaseConfigSchema; @@ -95,6 +111,8 @@ export const ThinkingConfigSchema = z enabled: z.boolean().optional(), budgetTokens: z.number().int().min(1_024).optional(), }) + .passthrough() + .passthrough() .superRefine((value, ctx) => { if (value.enabled && value.budgetTokens === undefined) { ctx.addIssue({ @@ -106,8 +124,10 @@ export const ThinkingConfigSchema = z }); export const AnthropicThinkingConfigSchema = AnthropicBaseConfigSchema.extend({ - thinking: ThinkingConfigSchema.optional(), -}); + thinking: ThinkingConfigSchema.optional().describe( + 'The thinking configuration to use for the request. Thinking is a feature that allows the model to think about the request and provide a better response.' + ), +}).passthrough(); export const AnthropicConfigSchema = AnthropicThinkingConfigSchema; diff --git a/js/plugins/anthropic/src/utils.ts b/js/plugins/anthropic/src/utils.ts new file mode 100644 index 0000000000..6678eabc19 --- /dev/null +++ b/js/plugins/anthropic/src/utils.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +export function removeUndefinedProperties(obj: T): T { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined) + ) as T; +} diff --git a/js/testapps/anthropic/package.json b/js/testapps/anthropic/package.json index 08e1a0d2fd..56a2ffc613 100644 --- a/js/testapps/anthropic/package.json +++ b/js/testapps/anthropic/package.json @@ -10,6 +10,7 @@ "start:beta": "node lib/beta/basic.js", "dev:stable": "genkit start -- npx tsx --watch src/stable/basic.ts", "dev:beta": "genkit start -- npx tsx --watch src/beta/basic.ts", + "dev:beta:additional-params": "genkit start -- npx tsx --watch src/beta/additional_params.ts", "dev:stable:text-plain": "genkit start -- npx tsx --watch src/stable/text-plain.ts", "dev:stable:webp": "genkit start -- npx tsx --watch src/stable/webp.ts", "dev:stable:pdf": "genkit start -- npx tsx --watch src/stable/pdf.ts", diff --git a/js/testapps/anthropic/src/beta/additional_params.ts b/js/testapps/anthropic/src/beta/additional_params.ts new file mode 100644 index 0000000000..2e443fc01a --- /dev/null +++ b/js/testapps/anthropic/src/beta/additional_params.ts @@ -0,0 +1,83 @@ +/** + * 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 { anthropic } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [ + // Default all flows in this sample to the beta surface + anthropic({ + apiVersion: 'beta', + cacheSystemPrompt: true, + apiKey: process.env.ANTHROPIC_API_KEY, + }), + ], +}); + +const betaOpus45 = anthropic.model('claude-opus-4-5', { apiVersion: 'beta' }); + +ai.defineFlow('anthropic-beta-additional-params', async () => { + const { text } = await ai.generate({ + model: betaOpus45, + prompt: + 'You are Claude on the beta API. Provide a concise greeting that mentions that you are using the beta API.', + config: { + temperature: 0.6, + // Additional param (not directly supported by the plugin, but can be passed through to the API) + betas: ['effort-2025-11-24'], + // Additional param (not directly supported by the plugin, but can be passed through to the API) + output_config: { + effort: 'medium', + }, + }, + }); + + return text; +}); + +ai.defineFlow( + 'anthropic-beta-additional-params-stream', + async (_, { sendChunk }) => { + const { stream } = ai.generateStream({ + model: betaOpus45, + prompt: [ + { + text: 'Outline two experimental capabilities unlocked by the Anthropic beta API.', + }, + ], + config: { + temperature: 0.4, + // Additional param (not directly supported by the plugin, but can be passed through to the API) + betas: ['effort-2025-11-24'], + // Additional param (not directly supported by the plugin, but can be passed through to the API) + output_config: { + effort: 'medium', + }, + }, + }); + + const collected: string[] = []; + for await (const chunk of stream) { + if (chunk.text) { + collected.push(chunk.text); + sendChunk(chunk.text); + } + } + + return collected.join(''); + } +);