diff --git a/.github/workflows/check_code_formatting.yml b/.github/workflows/check_code_formatting.yml index 60f46606b..e5303906d 100644 --- a/.github/workflows/check_code_formatting.yml +++ b/.github/workflows/check_code_formatting.yml @@ -21,5 +21,8 @@ jobs: - name: Install dependencies run: npm install + - name: Test request parameter mappings + run: npm run test:parameters + - name: Check formatting run: npm run format:check diff --git a/package.json b/package.json index 30ba6551b..1492a9e40 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "format": "prettier --write \"./**/*.{js,jsx,ts,tsx,json}\"", "format:check": "prettier --check \"./**/*.{js,jsx,ts,tsx,json}\"", "test:gateway": "jest src/", + "test:parameters": "tsx --test tests/unit/src/services/transformToProviderRequest.node.ts", "test:plugins": "jest plugins/", "pre-push": "npm run build && node start-test.js", "prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky", diff --git a/src/providers/azure-openai/chatComplete.ts b/src/providers/azure-openai/chatComplete.ts index 4a89911d1..a07a208c2 100644 --- a/src/providers/azure-openai/chatComplete.ts +++ b/src/providers/azure-openai/chatComplete.ts @@ -79,6 +79,9 @@ export const AzureOpenAIChatCompleteConfig: ProviderConfig = { tool_choice: { param: 'tool_choice', }, + parallel_tool_calls: { + param: 'parallel_tool_calls', + }, response_format: { param: 'response_format', }, diff --git a/src/providers/bedrock/chatComplete.ts b/src/providers/bedrock/chatComplete.ts index 2a132a1fd..d570d901d 100644 --- a/src/providers/bedrock/chatComplete.ts +++ b/src/providers/bedrock/chatComplete.ts @@ -71,6 +71,9 @@ export interface BedrockConverseAnthropicChatCompletionsParams type: string; budget_tokens: number; }; + output_config?: { + effort?: string; + }; anthropic_beta?: string | string[]; } @@ -717,6 +720,26 @@ export const BedrockChatCompleteStreamChunkTransform: ( export const BedrockConverseAnthropicChatCompleteConfig: ProviderConfig = { ...BedrockConverseChatCompleteConfig, + response_format: { + param: 'outputConfig', + transform: (params: BedrockConverseAnthropicChatCompletionsParams) => { + if (params.response_format?.type !== 'json_schema') return; + const { name, description, schema } = + params.response_format.json_schema || {}; + return { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + name, + description, + schema: JSON.stringify(schema), + }, + }, + }, + }; + }, + }, additionalModelRequestFields: { param: 'additionalModelRequestFields', transform: ( @@ -765,6 +788,14 @@ export const BedrockConverseAnthropicChatCompleteConfig: ProviderConfig = { ) => transformAnthropicAdditionalModelRequestFields(params, providerOptions), }, + output_config: { + param: 'additionalModelRequestFields', + transform: ( + params: BedrockConverseAnthropicChatCompletionsParams, + providerOptions?: Options + ) => + transformAnthropicAdditionalModelRequestFields(params, providerOptions), + }, anthropic_beta: { param: 'additionalModelRequestFields', transform: ( diff --git a/src/providers/bedrock/utils.ts b/src/providers/bedrock/utils.ts index 40b72983d..e5fb19e5c 100644 --- a/src/providers/bedrock/utils.ts +++ b/src/providers/bedrock/utils.ts @@ -133,6 +133,12 @@ export const transformAnthropicAdditionalModelRequestFields = ( if (params['thinking']) { additionalModelRequestFields['thinking'] = params['thinking']; } + if (params.output_config?.effort !== undefined) { + additionalModelRequestFields['output_config'] = { + ...additionalModelRequestFields['output_config'], + effort: params.output_config.effort, + }; + } const anthropicBeta = providerOptions?.anthropicBeta || params['anthropic_beta']; if (anthropicBeta) { diff --git a/src/providers/openai/chatComplete.ts b/src/providers/openai/chatComplete.ts index 1186061af..b7dcd6a00 100644 --- a/src/providers/openai/chatComplete.ts +++ b/src/providers/openai/chatComplete.ts @@ -42,6 +42,9 @@ export const OpenAIChatCompleteConfig: ProviderConfig = { min: 0, max: 1, }, + top_k: { + param: 'top_k', + }, n: { param: 'n', default: 1, diff --git a/tests/unit/src/services/transformToProviderRequest.node.ts b/tests/unit/src/services/transformToProviderRequest.node.ts new file mode 100644 index 000000000..b768fdd31 --- /dev/null +++ b/tests/unit/src/services/transformToProviderRequest.node.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AZURE_OPEN_AI, BEDROCK, OPEN_AI } from '../../../../src/globals'; +import { BedrockConverseAnthropicChatCompletionsParams } from '../../../../src/providers/bedrock/chatComplete'; +import { Params } from '../../../../src/types/requestBody'; +import { transformToProviderRequest } from '../../../../src/services/transformToProviderRequest'; + +const chatRequest = (provider: string, params: Params) => + JSON.parse( + JSON.stringify( + transformToProviderRequest( + provider, + params, + params, + 'chatComplete', + {}, + { + provider, + } + ) + ) + ); + +describe('explicit chat parameters', () => { + for (const top_k of [0, 40]) { + it(`forwards OpenAI-compatible top_k=${top_k} unchanged`, () => { + assert.equal( + chatRequest(OPEN_AI, { model: 'example', top_k }).top_k, + top_k + ); + }); + } + + for (const value of [true, false]) { + it(`forwards Azure parallel_tool_calls=${value}`, () => { + const params = { model: 'example', parallel_tool_calls: value }; + assert.equal( + chatRequest(AZURE_OPEN_AI, params).parallel_tool_calls, + value + ); + }); + } + + for (const provider of [OPEN_AI, AZURE_OPEN_AI]) { + it(`does not inject optional ${provider} parameters`, () => { + const request = chatRequest(provider, { model: 'example' }); + assert.equal('top_k' in request, false); + assert.equal('parallel_tool_calls' in request, false); + }); + } + + it('still filters unrelated parameters', () => { + const params = { model: 'example', unknown_parameter: true }; + assert.equal('unknown_parameter' in chatRequest(OPEN_AI, params), false); + }); +}); + +describe('Claude Converse parameters', () => { + const model = 'us.anthropic.claude-sonnet-4-6'; + const json_schema = { + name: 'answer', + description: 'A short answer', + strict: true, + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }, + }; + + it('maps JSON schema to the Converse output format, not native response_format', () => { + const params: Params = { + model, + response_format: { type: 'json_schema', json_schema }, + }; + const request = chatRequest(BEDROCK, params); + assert.deepEqual(request.outputConfig, { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + name: json_schema.name, + description: json_schema.description, + schema: JSON.stringify(json_schema.schema), + }, + }, + }, + }); + assert.equal('response_format' in request, false); + assert.equal('additionalModelRequestFields' in request, false); + }); + + for (const type of ['json_object', 'text'] as const) { + it(`does not invent a schema for ${type}`, () => { + const request = chatRequest(BEDROCK, { + model, + response_format: { type }, + }); + assert.equal('outputConfig' in request, false); + assert.equal('response_format' in request, false); + }); + } + + it('preserves effort, thinking and existing native fields alongside structured output', () => { + const params = { + model, + thinking: { type: 'enabled', budget_tokens: 1024 }, + output_config: { effort: 'low' }, + additionalModelRequestFields: { metadata: { user_id: 'example' } }, + response_format: { type: 'json_schema', json_schema }, + } satisfies BedrockConverseAnthropicChatCompletionsParams; + const request = chatRequest(BEDROCK, params); + assert.deepEqual(request.additionalModelRequestFields, { + metadata: { user_id: 'example' }, + thinking: params.thinking, + output_config: { effort: 'low' }, + }); + assert.equal( + request.outputConfig.textFormat.structure.jsonSchema.schema, + JSON.stringify(json_schema.schema) + ); + }); + + it('forwards effort when it is the only native option', () => { + const params = { + model, + output_config: { effort: 'high' }, + } satisfies BedrockConverseAnthropicChatCompletionsParams; + assert.deepEqual( + chatRequest(BEDROCK, params).additionalModelRequestFields, + { + output_config: { effort: 'high' }, + } + ); + }); + + it('does not inject effort or a schema when omitted', () => { + const request = chatRequest(BEDROCK, { model, max_tokens: 64 }); + assert.equal('outputConfig' in request, false); + assert.equal('additionalModelRequestFields' in request, false); + assert.deepEqual(request.inferenceConfig, { maxTokens: 64 }); + }); + + it('does not apply Anthropic output settings to other Bedrock models', () => { + const params = { + model: 'amazon.nova-2-lite-v1:0', + output_config: { effort: 'low' }, + response_format: { type: 'json_schema', json_schema }, + } satisfies BedrockConverseAnthropicChatCompletionsParams; + const request = chatRequest(BEDROCK, params); + assert.equal('outputConfig' in request, false); + assert.equal('additionalModelRequestFields' in request, false); + }); +});