From a9bc9052e8a2f9967bc8fcdfc85e7371df77b025 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Fri, 18 Sep 2026 16:38:04 -0400 Subject: [PATCH 01/23] fix(claude-agents): forward maxTurns and model.parameters to query() buildQueryOptions already accepted an unused extra options bag; the call site never filled it. An AI Config could set a turn cap or agent-run tuning knobs and this handler silently dropped them, so an agent driven through this SDK ran with no budget at all. Now config.model.parameters is checked against the Options keys the Claude Agent SDK actually accepts (maxTurns, maxThinkingTokens, maxBudgetUsd, effort, fallbackModel, thinking) and only those are forwarded. Anything else, including temperature and max_tokens which this SDK has no equivalent for, is ignored rather than passed through. No default is added, so a config that sets nothing behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/handler.test.ts | 61 +++++++++++++++++++ packages/claude-agents/src/handler.ts | 33 ++++++++++ 2 files changed, 94 insertions(+) diff --git a/packages/claude-agents/src/__tests__/handler.test.ts b/packages/claude-agents/src/__tests__/handler.test.ts index b228fe4a..c33e2520 100644 --- a/packages/claude-agents/src/__tests__/handler.test.ts +++ b/packages/claude-agents/src/__tests__/handler.test.ts @@ -189,6 +189,55 @@ describe('createClaudeAgentsHandler', () => { expect(result.output).toBe('final answer'); }); + it('forwards model.parameters.maxTurns to query options', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { maxTurns: 3 } } }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxTurns).toBe(3); + }); + + it('forwards recognized model.parameters keys to query options', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { maxThinkingTokens: 2048, maxBudgetUsd: 1.5, effort: 'high', fallbackModel: 'claude-haiku' }, + }, + }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxThinkingTokens).toBe(2048); + expect(options.maxBudgetUsd).toBe(1.5); + expect(options.effort).toBe('high'); + expect(options.fallbackModel).toBe('claude-haiku'); + }); + + it('ignores unrecognized model.parameters keys rather than forwarding them', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { temperature: 0.7, max_tokens: 100 } }, + }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.temperature).toBeUndefined(); + expect(options.max_tokens).toBeUndefined(); + }); + + it('does not set maxTurns or other forwarded keys when model.parameters is absent', async () => { + mockQuery.mockImplementation(makeResultMessage()); + await createClaudeAgentsHandler()(baseConfig as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxTurns).toBeUndefined(); + expect(options.maxThinkingTokens).toBeUndefined(); + expect(options.maxBudgetUsd).toBeUndefined(); + expect(options.effort).toBeUndefined(); + expect(options.fallbackModel).toBeUndefined(); + expect(options.thinking).toBeUndefined(); + }); + // ── 1.3 Tool wiring ───────────────────────────────────────────────────────── it('builds MCP server when config.tools has user-defined tools', async () => { @@ -492,6 +541,18 @@ describe('createClaudeAgentsHandler', () => { expect(done.output).toBe('Final answer'); }); + it('forwards model.parameters.maxTurns alongside includePartialMessages on the streaming path', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', subtype: 'success', result: 'done', usage: {} }; + }); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { maxTurns: 5 } } }; + const handler = createClaudeAgentsHandler(); + await collectStream(handler.stream?.(config as any, 'q', {}, {})); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxTurns).toBe(5); + expect(options.includePartialMessages).toBe(true); + }); + it('sets span attributes and puts content on attributes when enabled', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', subtype: 'success', result: 'ok', usage: { input_tokens: 2, output_tokens: 1 } }; diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index ebf30c1d..8a87ee7a 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -882,6 +882,37 @@ function buildOpeningMessages( return turns.map((turn) => ({ role: turn.role, parts: turnToSpanParts(turn.content) })); } +/** + * `Options` keys this handler forwards verbatim from `config.model.parameters`, so an AI Config can + * cap turns or tune agent-run behaviour without a code change here. Anthropic's raw Messages API + * knobs (`temperature`, `max_tokens`, …) have no equivalent on this SDK's `Options` — the Claude + * Agent SDK runs the CLI, not a completions call — so keys outside this list are ignored rather + * than spread through, per the contract that unrecognized `model.parameters` keys are dropped, not + * forwarded to a shape the SDK does not accept. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'maxTurns', + 'maxThinkingTokens', + 'maxBudgetUsd', + 'effort', + 'fallbackModel', + 'thinking', +] as const; + +/** + * Picks the subset of `config.model.parameters` that maps onto `query()`'s `Options` type, + * unchanged otherwise: no default turn cap, no default budget — a config that sets nothing here + * produces `{}`, so `query()` sees exactly what it always has. + */ +function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + if (!parameters) return {}; + const forwarded: Record = {}; + for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { + if (parameters[key] !== undefined) forwarded[key] = parameters[key]; + } + return forwarded; +} + function buildQueryOptions( config: AiConfigRep, prompt: string | AsyncIterable, @@ -981,6 +1012,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, + buildModelParameterOptions(config.model.parameters), ), )) { recordConversationId(span, message); @@ -1108,6 +1140,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap toolMCP, toolTelemetry?.hooks, { + ...buildModelParameterOptions(config.model.parameters), includePartialMessages: true, }, ), From f6cb53ef1d596b73eba01646645e0106b72843ca Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 10:34:27 -0400 Subject: [PATCH 02/23] fix(openai-messages): forward model.parameters to the Responses API The handler built responses.create()/responses.stream() calls as only {model, input, tools}, so a customer who set temperature, top_p, or max_output_tokens in an AI Config had it silently dropped on every call, blocking and streaming alike. config.model.parameters is now checked against an explicit allowlist of ResponseCreateParams keys the Responses API actually accepts (temperature, top_p, max_output_tokens, top_logprobs, parallel_tool_calls, reasoning, truncation, metadata, store, service_tier, prompt_cache_key, safety_identifier), and only those are forwarded, on every call site: the initial turn, the tool-loop follow-up turn, and both branches of the streaming path. The allowlist is spread first so model, input, tools, and previous_response_id - computed by the handler itself - always win, so a parameters bag can never smuggle those back in. A config that sets nothing produces {}, so the request sent is unchanged from before. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/handler.test.ts | 77 +++++++++++++++++++ packages/openai-messages/src/handler.ts | 52 ++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index 328dc740..6ed4f5dc 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -301,6 +301,59 @@ describe('createOpenAIHandler', () => { expect(call.tools ?? []).toHaveLength(0); }); + // ── 1.3b model.parameters forwarding ──────────────────────────────────────── + + it('forwards allowlisted model.parameters fields to the provider call', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { temperature: 0.4, top_p: 0.9, max_output_tokens: 256 }, + }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.temperature).toBe(0.4); + expect(call.top_p).toBe(0.9); + expect(call.max_output_tokens).toBe(256); + }); + + it('drops unrecognized model.parameters keys rather than forwarding them', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { max_tokens: 999, foo: 'bar' } }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.max_tokens).toBeUndefined(); + expect(call.foo).toBeUndefined(); + }); + + it('does not let model.parameters override model, input, tools, or previous_response_id', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { model: 'evil-model', input: 'evil-input', tools: ['evil'], previous_response_id: 'evil-id' }, + }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.model).toBe('gpt-4o'); + expect(call.previous_response_id).toBeUndefined(); + expect(call.tools ?? []).toHaveLength(0); + }); + + it('behaves identically to today when model.parameters is absent', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + await createOpenAIHandler()(baseConfig as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call).toEqual({ model: 'gpt-4o', input: expect.any(Array) }); + }); + // ── 1.4 Tool execution loop ───────────────────────────────────────────────── it('invokes the tool handler and loops until end', async () => { @@ -667,6 +720,30 @@ describe('createOpenAIHandler', () => { expect(typeof handler.stream).toBe('function'); }); + it('forwards allowlisted model.parameters fields to the streaming provider call', async () => { + mockResponsesStream.mockReturnValue( + makeResponseStreamMock([{ type: 'response.output_text.delta', delta: 'hi' }], baseFinalResponse), + ); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { temperature: 0.4, top_p: 0.9, max_output_tokens: 256 } }, + }; + await collectStream(createOpenAIHandler().stream?.(config as any, 'q', {}, {})); + const call = mockResponsesStream.mock.calls[0][0]; + expect(call.temperature).toBe(0.4); + expect(call.top_p).toBe(0.9); + expect(call.max_output_tokens).toBe(256); + }); + + it('the streaming call is unchanged from today when model.parameters is absent', async () => { + mockResponsesStream.mockReturnValue( + makeResponseStreamMock([{ type: 'response.output_text.delta', delta: 'hi' }], baseFinalResponse), + ); + await collectStream(createOpenAIHandler().stream?.(baseConfig as any, 'q', {}, {})); + const call = mockResponsesStream.mock.calls[0][0]; + expect(call).toEqual({ model: 'gpt-4o', input: expect.any(Array) }); + }); + it('handler.stream returns an async iterable', () => { mockResponsesStream.mockReturnValue( makeResponseStreamMock([{ type: 'response.output_text.delta', delta: 'hi' }], baseFinalResponse), diff --git a/packages/openai-messages/src/handler.ts b/packages/openai-messages/src/handler.ts index 71e1368e..e908b3d6 100644 --- a/packages/openai-messages/src/handler.ts +++ b/packages/openai-messages/src/handler.ts @@ -331,6 +331,42 @@ const jsonSchemaFormat = (schema: Record): any => ({ const toToolDefinitions = (tools: FunctionTool[]): ToolDefinitionInput[] => tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters })); +/** + * `ResponseCreateParams` keys this handler forwards verbatim from `config.model.parameters`, so an + * AI Config can tune sampling and token limits without a code change here. Kept to an explicit + * allowlist, not a spread, because the Responses API's request type is closed and a customer must + * not be able to smuggle `model`, `input`, `tools`, or `previous_response_id` back in through + * `model.parameters` and quietly override the values this handler computes itself. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'temperature', + 'top_p', + 'max_output_tokens', + 'top_logprobs', + 'parallel_tool_calls', + 'reasoning', + 'truncation', + 'metadata', + 'store', + 'service_tier', + 'prompt_cache_key', + 'safety_identifier', +] as const; + +/** + * Picks the subset of `config.model.parameters` that maps onto `ResponseCreateParams`, unchanged + * otherwise: no default temperature, no default cap. A config that sets nothing here produces + * `{}`, so the request sent is byte-for-byte what it always was. + */ +function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + if (!parameters) return {}; + const forwarded: Record = {}; + for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { + if (parameters[key] !== undefined) forwarded[key] = parameters[key]; + } + return forwarded; +} + export function createOpenAIHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { const openai = new OpenAI(); @@ -403,6 +439,7 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp let response = await runModelTurn( { + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, input: inputMessages, ...(tools.length > 0 ? { tools } : {}), @@ -445,6 +482,7 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp response = await runModelTurn( { + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, previous_response_id: response.id, input: toolOutputs, @@ -526,8 +564,18 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp let finalResp: OpenAI.Responses.Response; try { const streamParams = previousResponseId - ? { model: config.model.name, previous_response_id: previousResponseId, input: currentInput } - : { model: config.model.name, input: currentInput, ...(tools.length > 0 ? { tools } : {}) }; + ? { + ...buildModelParameterOptions(config.model.parameters), + model: config.model.name, + previous_response_id: previousResponseId, + input: currentInput, + } + : { + ...buildModelParameterOptions(config.model.parameters), + model: config.model.name, + input: currentInput, + ...(tools.length > 0 ? { tools } : {}), + }; // biome-ignore lint/suspicious/noExplicitAny: streamParams union type does not match the SDK's overloaded stream() signature const responseStream = openai.responses.stream(streamParams as any); From 23aea7d6591c56f0a8430d74f82f0de41c3d0f08 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 10:34:43 -0400 Subject: [PATCH 03/23] fix(claude-messages): forward model.parameters to messages.create/stream The handler only ever read config.model.parameters.max_tokens; every other Anthropic Messages API knob a customer set in an AI Config (temperature, top_p, top_k, stop_sequences, thinking, and the rest of MessageCreateParamsBase) was silently dropped. Now an explicit allowlist, taken from the SDK's own MessageCreateParamsBase type, is forwarded from model.parameters at both call sites (the blocking tool loop and the streaming tool loop). The allowlist excludes model, messages, tools, system, max_tokens, and stream, so a config value can never override what the handler already decided for those. max_tokens keeps its existing behaviour and 1024 default unchanged. A config that sets no parameters, or an empty parameters object, produces the exact same call as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/handler.test.ts | 181 ++++++++++++++++++ packages/claude-messages/src/handler.ts | 38 ++++ 2 files changed, 219 insertions(+) diff --git a/packages/claude-messages/src/__tests__/handler.test.ts b/packages/claude-messages/src/__tests__/handler.test.ts index 56d51c91..d4176af6 100644 --- a/packages/claude-messages/src/__tests__/handler.test.ts +++ b/packages/claude-messages/src/__tests__/handler.test.ts @@ -1259,3 +1259,184 @@ describe('createClaudeMessagesHandler — MAX_STEPS cap (§1.10)', () => { ); }); }); + +describe('createClaudeMessagesHandler — model.parameters forwarding', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockChildSpans.length = 0; + }); + + function makeStreamMock(events: any[], finalMsg: any) { + const asyncIterable = (async function* () { + for (const e of events) yield e; + })(); + return { + [Symbol.asyncIterator]: () => asyncIterable[Symbol.asyncIterator](), + finalMessage: vi.fn().mockResolvedValue(finalMsg), + }; + } + + it('forwards recognized keys (temperature, top_p, top_k, stop_sequences, thinking) to messages.create', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + temperature: 0.5, + top_p: 0.9, + top_k: 40, + stop_sequences: ['STOP'], + thinking: { type: 'enabled', budget_tokens: 2048 }, + }, + }, + }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call.temperature).toBe(0.5); + expect(call.top_p).toBe(0.9); + expect(call.top_k).toBe(40); + expect(call.stop_sequences).toEqual(['STOP']); + expect(call.thinking).toEqual({ type: 'enabled', budget_tokens: 2048 }); + }); + + it('still defaults max_tokens to 1024 when model.parameters is set but omits it', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { temperature: 0.3 } } }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + expect(mockMessagesCreate.mock.calls[0][0].max_tokens).toBe(1024); + }); + + it('forwards a configured max_tokens unchanged (existing behaviour)', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_tokens: 2048 } } }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + expect(mockMessagesCreate.mock.calls[0][0].max_tokens).toBe(2048); + }); + + it('does not let model.parameters override model, messages, tools, or system', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + model: 'evil-model', + messages: ['evil'], + tools: ['evil'], + system: 'evil system', + stream: true, + }, + }, + }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call.model).toBe(baseConfig.model.name); + expect(call.system).toBe('You are helpful.'); + expect(Array.isArray(call.messages)).toBe(true); + expect(call.messages.at(-1).content).toBe('hi'); + expect(call.tools).toBeUndefined(); + }); + + it('drops unknown model.parameters keys', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { made_up_key: 'nope' } } }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + expect(mockMessagesCreate.mock.calls[0][0]).not.toHaveProperty('made_up_key'); + }); + + it('leaves the call unchanged from today when model.parameters is absent', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const handler = createClaudeMessagesHandler(); + await handler(baseConfig as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call).toEqual({ + model: baseConfig.model.name, + max_tokens: 1024, + system: 'You are helpful.', + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('leaves the call unchanged from today when model.parameters is an empty object', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: {} } }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call).toEqual({ + model: baseConfig.model.name, + max_tokens: 1024, + system: 'You are helpful.', + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + // ── streaming path ────────────────────────────────────────────────────────── + + it('forwards recognized model.parameters to messages.stream', async () => { + const finalMsg = { + usage: { input_tokens: 3, output_tokens: 7 }, + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi' }], + }; + mockMessagesStream.mockReturnValue(makeStreamMock([], finalMsg)); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { temperature: 0.7, top_k: 10 } } }; + const handler = createClaudeMessagesHandler(); + const gen = handler.stream?.(cfg as any, 'q', {}, {}); + for await (const _e of gen) { + // drain + } + const call = mockMessagesStream.mock.calls[0][0]; + expect(call.temperature).toBe(0.7); + expect(call.top_k).toBe(10); + }); + + it('streaming path still defaults max_tokens to 1024 and drops unknown keys', async () => { + const finalMsg = { + usage: { input_tokens: 3, output_tokens: 7 }, + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi' }], + }; + mockMessagesStream.mockReturnValue(makeStreamMock([], finalMsg)); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { made_up_key: 'nope' } } }; + const handler = createClaudeMessagesHandler(); + const gen = handler.stream?.(cfg as any, 'q', {}, {}); + for await (const _e of gen) { + // drain + } + const call = mockMessagesStream.mock.calls[0][0]; + expect(call.max_tokens).toBe(1024); + expect(call).not.toHaveProperty('made_up_key'); + }); + + it('streaming path does not let model.parameters override model/messages/system', async () => { + const finalMsg = { + usage: { input_tokens: 3, output_tokens: 7 }, + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi' }], + }; + mockMessagesStream.mockReturnValue(makeStreamMock([], finalMsg)); + const cfg = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { model: 'evil-model', system: 'evil system', messages: ['evil'] }, + }, + }; + const handler = createClaudeMessagesHandler(); + const gen = handler.stream?.(cfg as any, 'q', {}, {}); + for await (const _e of gen) { + // drain + } + const call = mockMessagesStream.mock.calls[0][0]; + expect(call.model).toBe(baseConfig.model.name); + expect(call.system).toBe('You are helpful.'); + expect(call.messages.at(-1).content).toBe('q'); + }); +}); diff --git a/packages/claude-messages/src/handler.ts b/packages/claude-messages/src/handler.ts index 1b5d8416..48f1e510 100644 --- a/packages/claude-messages/src/handler.ts +++ b/packages/claude-messages/src/handler.ts @@ -329,6 +329,42 @@ const buildMessages = ( return { messages, system }; }; +/** + * `MessageCreateParamsBase` keys this handler forwards verbatim from `config.model.parameters`, + * beyond `max_tokens`, which is handled separately because it carries a default. Excludes + * `model`, `messages`, `tools`, `system`, `max_tokens`, and `stream`, which this handler sets + * itself from the config and the call shape — a `model.parameters` value for any of those must + * not be able to override what the handler already decided. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'cache_control', + 'container', + 'inference_geo', + 'metadata', + 'output_config', + 'service_tier', + 'stop_sequences', + 'temperature', + 'thinking', + 'tool_choice', + 'top_k', + 'top_p', +] as const; + +/** + * Picks the subset of `config.model.parameters` that maps onto `MessageCreateParamsBase`, + * unchanged otherwise: a config that sets nothing here produces `{}`, so the provider call sees + * exactly what it always has. + */ +function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + if (!parameters) return {}; + const forwarded: Record = {}; + for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { + if (parameters[key] !== undefined) forwarded[key] = parameters[key]; + } + return forwarded; +} + const MAX_STEPS = 10; export function createClaudeMessagesHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { @@ -366,6 +402,7 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let response: Anthropic.Message; try { response = await anthropic.messages.create({ + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, max_tokens: maxTokens, ...(system ? { system } : {}), @@ -526,6 +563,7 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let finalMsg: Anthropic.Message; try { const stream = anthropic.messages.stream({ + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, max_tokens: maxTokens, ...(system ? { system } : {}), From 1f146327eadd74163059fd722795074f2712f685 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 10:36:03 -0400 Subject: [PATCH 04/23] fix(openai-agents): forward maxTurns and model.parameters to Runner.run Runner.run already accepts maxTurns and the Agents SDK's ModelSettings tunes temperature, topP, and similar knobs, but the handler never read either from config.model.parameters. An agent driven through this SDK ran with no turn budget at all, and a customer's temperature/topP/etc. settings silently did nothing. Now config.model.parameters is checked against the ModelSettings keys the Agents SDK's Agent constructor actually accepts (temperature, topP, frequencyPenalty, presencePenalty, maxTokens, toolChoice, parallelToolCalls, truncation, store, promptCacheRetention) and only those are forwarded as modelSettings. maxTurns is read separately and passed to Runner.run's options, since it caps the agentic loop rather than tuning a model call. Both the blocking and streaming call paths are covered. Anything else in model.parameters is ignored rather than forwarded. No default is added, so a config that sets nothing behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/handler.test.ts | 82 +++++++++++++++++++ packages/openai-agents/src/handler.ts | 56 ++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/packages/openai-agents/src/__tests__/handler.test.ts b/packages/openai-agents/src/__tests__/handler.test.ts index 46457c27..740d25c3 100644 --- a/packages/openai-agents/src/__tests__/handler.test.ts +++ b/packages/openai-agents/src/__tests__/handler.test.ts @@ -227,6 +227,56 @@ describe('createOpenAIAgentHandler', () => { expect(mockAgentConstructor.mock.calls[0][0].instructions).toBe('From instructions.'); }); + // ── 1.2b model.parameters forwarding ───────────────────────────────────────── + + it('forwards recognized model.parameters keys to Agent modelSettings', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { temperature: 0.4, topP: 0.9, maxTokens: 256, frequencyPenalty: 0.1, presencePenalty: 0.2 }, + }, + }; + await createOpenAIAgentHandler()(config as any, 'q'); + const agentArgs = mockAgentConstructor.mock.calls[0][0]; + expect(agentArgs.modelSettings).toEqual({ + temperature: 0.4, + topP: 0.9, + maxTokens: 256, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + }); + }); + + it('ignores unrecognized model.parameters keys rather than forwarding them', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { someUnknownKey: 'nope', anotherOne: 42 } }, + }; + await createOpenAIAgentHandler()(config as any, 'q'); + const agentArgs = mockAgentConstructor.mock.calls[0][0]; + expect(agentArgs.modelSettings).toBeUndefined(); + }); + + it('does not set modelSettings or maxTurns when model.parameters is absent', async () => { + mockRun.mockResolvedValue(mockRunResult()); + await createOpenAIAgentHandler()(baseConfig as any, 'q'); + const agentArgs = mockAgentConstructor.mock.calls[0][0]; + expect(agentArgs.modelSettings).toBeUndefined(); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions).toBeUndefined(); + }); + + it('forwards model.parameters.maxTurns to Runner.run options', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { maxTurns: 3 } } }; + await createOpenAIAgentHandler()(config as any, 'q'); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions).toEqual({ maxTurns: 3 }); + }); + // ── 1.3 Tool conversion ───────────────────────────────────────────────────── it('creates agent tools when config.tools is present', async () => { @@ -560,6 +610,38 @@ describe('createOpenAIAgentHandler', () => { expect(done.usage).toMatchObject({ input_tokens: 4, output_tokens: 6 }); }); + it('forwards model.parameters (modelSettings and maxTurns) on the streaming path', async () => { + const streamedResult = { + [Symbol.asyncIterator]: async function* () {}, + state: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + finalOutput: '', + }; + mockRun.mockResolvedValue(streamedResult); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { temperature: 0.5, maxTurns: 5, unknownKey: 'nope' } }, + }; + const handler = createOpenAIAgentHandler(); + await collectStream(handler.stream?.(config as any, 'q', {}, {})); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ temperature: 0.5 }); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions).toMatchObject({ maxTurns: 5 }); + }); + + it('does not set modelSettings or maxTurns on the streaming path when model.parameters is absent', async () => { + const streamedResult = { + [Symbol.asyncIterator]: async function* () {}, + state: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + finalOutput: '', + }; + mockRun.mockResolvedValue(streamedResult); + const handler = createOpenAIAgentHandler(); + await collectStream(handler.stream?.(baseConfig as any, 'q', {}, {})); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toBeUndefined(); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions.maxTurns).toBeUndefined(); + }); + it('sets invoke_agent on the root and emits a chat span on streaming', async () => { mockInnerGetStreamed.mockImplementation(async function* () { yield { type: 'response_done', response: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } } }; diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index b0f3db3f..d94bac82 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -33,6 +33,7 @@ import type { ModelProvider, ModelRequest, ModelResponse, + ModelSettings, RunRawModelStreamEvent, StreamEvent, StreamedRunResult, @@ -495,6 +496,52 @@ function configConversationTurns(config: AiConfigRep, variables: Record ({ role: m.role as 'user' | 'assistant', content: parseTemplate(m.content, variables) })); } +/** + * `ModelSettings` keys this handler forwards verbatim from `config.model.parameters`, so an AI + * Config can tune the underlying model call without a code change here. Kept to the scalar tuning + * knobs plus `toolChoice`/`truncation`/`promptCacheRetention`, which are the settings customers + * configure through the LaunchDarkly UI today; `providerData` and `retry` are left out because + * they are SDK escape hatches (arbitrary provider-specific payload, client-side retry behaviour) + * rather than model tuning parameters, and forwarding them unreviewed would let a config reach + * past this handler's own request shape. + */ +const FORWARDED_MODEL_SETTINGS_KEYS = [ + 'temperature', + 'topP', + 'frequencyPenalty', + 'presencePenalty', + 'maxTokens', + 'toolChoice', + 'parallelToolCalls', + 'truncation', + 'store', + 'promptCacheRetention', +] as const satisfies ReadonlyArray; + +/** + * Picks the subset of `config.model.parameters` that maps onto the Agents SDK's `ModelSettings`, + * unchanged otherwise: no default temperature, no default cap — a config that sets nothing here + * produces `undefined`, so the Agent is constructed exactly as it always has been. + */ +function buildModelSettings(parameters: AiConfigRep['model']['parameters']): ModelSettings | undefined { + if (!parameters) return undefined; + const settings: Record = {}; + for (const key of FORWARDED_MODEL_SETTINGS_KEYS) { + if (parameters[key] !== undefined) settings[key] = parameters[key]; + } + return Object.keys(settings).length > 0 ? (settings as ModelSettings) : undefined; +} + +/** + * `maxTurns` is a `Runner.run` option, not a `ModelSettings` field — it caps the agentic loop + * rather than tuning any single model call — so it is read out of `model.parameters` separately + * and forwarded to `run()` instead of the `Agent` constructor. + */ +function buildMaxTurns(parameters: AiConfigRep['model']['parameters']): number | undefined { + const maxTurns = parameters?.maxTurns; + return typeof maxTurns === 'number' ? maxTurns : undefined; +} + function buildAgentAndPrompt( config: AiConfigRep, userInput: string, @@ -536,6 +583,7 @@ function buildAgentAndPrompt( const tools = config.tools ? buildAgentTools(config.tools, toolHandlers) : []; const outputType = includeOutputType ? buildOutputType(config.outputFormat) : undefined; + const modelSettings = buildModelSettings(config.model.parameters); const agent = new Agent({ name: 'assistant', @@ -543,6 +591,7 @@ function buildAgentAndPrompt( ...(instructions ? { instructions } : {}), ...(tools.length > 0 ? { tools } : {}), ...(outputType ? { outputType } : {}), + ...(modelSettings ? { modelSettings } : {}), }); return { agent, prompt, instructions }; @@ -606,8 +655,9 @@ export function createOpenAIAgentHandler({ captureContent = false }: ContentCapt modelProvider: new SpanningModelProvider(defaultModelProvider(), config, parentContext, captureContent), }); try { + const maxTurns = buildMaxTurns(config.model.parameters); // biome-ignore lint/suspicious/noExplicitAny: Runner.run accepts string | AgentInputItem[]; our item shape is structurally compatible - const result = await runner.run(agent, prompt as any); + const result = await runner.run(agent, prompt as any, maxTurns !== undefined ? { maxTurns } : undefined); const finalOutput = result.finalOutput ?? ''; const { inputTokens, outputTokens } = result.state.usage; @@ -673,8 +723,10 @@ export function createOpenAIAgentHandler({ captureContent = false }: ContentCapt modelProvider: new SpanningModelProvider(defaultModelProvider(), config, parentContext, captureContent), }); try { + const maxTurns = buildMaxTurns(config.model.parameters); + const runOptions = { stream: true, signal: abortRun.signal, ...(maxTurns !== undefined ? { maxTurns } : {}) }; // biome-ignore lint/suspicious/noExplicitAny: Agents SDK run() stream overload requires an any-cast option - const streamed = await runner.run(agent, prompt as any, { stream: true, signal: abortRun.signal } as any); + const streamed = await runner.run(agent, prompt as any, runOptions as any); // biome-ignore lint/suspicious/noExplicitAny: StreamedRunResult generics are irrelevant to this handler const streamedResult = streamed as StreamedRunResult; let fullOutput = ''; From 6dfac3b3862171d13ae3907c7b643ffefb27e137 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 10:51:25 -0400 Subject: [PATCH 05/23] refactor(client): share model-parameter picking and normalizing helpers Extracts the duplicated `buildModelParameterOptions` / `buildModelSettings` picker loop (claude-agents, claude-messages, openai-messages, openai-agents) into `pickForwardedModelParameters`, and the repeated `config.model?.parameters && typeof ... === 'object' ? ... : {}` guard (langchain-messages, langchain-agents, langchain-agents/native-graph) into `normalizeModelParameters`. Both live in packages/client and are exported from its public index. Each handler keeps its own FORWARDED_*_KEYS allowlist next to the request shape it maps onto; the shared picker only owns the loop. openai-agents keeps collapsing an empty result to `undefined` itself. The LangChain handlers keep spreading the whole parameters object, unchanged, since their chat models take an open-ended options bag. No behaviour change: same keys picked, same spread order, same undefined vs {} results. Adds unit tests for both new helpers in packages/client. Co-Authored-By: Claude Opus 5 (1M context) --- packages/claude-agents/src/handler.ts | 8 +-- packages/claude-messages/src/handler.ts | 8 +-- packages/client/src/__tests__/utils.test.ts | 61 +++++++++++++++++++ packages/client/src/index.ts | 2 + packages/client/src/utils.ts | 44 +++++++++++++ packages/langchain-agents/src/handler.ts | 5 +- packages/langchain-agents/src/native-graph.ts | 5 +- packages/langchain-messages/src/handler.ts | 5 +- packages/openai-agents/src/handler.ts | 7 +-- packages/openai-messages/src/handler.ts | 8 +-- 10 files changed, 121 insertions(+), 32 deletions(-) diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index 8a87ee7a..f3f0dd2f 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -17,6 +17,7 @@ import { type NativeTool, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, setConversationIdIfAbsent, @@ -905,12 +906,7 @@ const FORWARDED_MODEL_PARAMETER_KEYS = [ * produces `{}`, so `query()` sees exactly what it always has. */ function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - if (!parameters) return {}; - const forwarded: Record = {}; - for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { - if (parameters[key] !== undefined) forwarded[key] = parameters[key]; - } - return forwarded; + return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); } function buildQueryOptions( diff --git a/packages/claude-messages/src/handler.ts b/packages/claude-messages/src/handler.ts index 48f1e510..de7b0128 100644 --- a/packages/claude-messages/src/handler.ts +++ b/packages/claude-messages/src/handler.ts @@ -15,6 +15,7 @@ import { type NativeTool, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, setInputContentAttributes, @@ -357,12 +358,7 @@ const FORWARDED_MODEL_PARAMETER_KEYS = [ * exactly what it always has. */ function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - if (!parameters) return {}; - const forwarded: Record = {}; - for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { - if (parameters[key] !== undefined) forwarded[key] = parameters[key]; - } - return forwarded; + return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); } const MAX_STEPS = 10; diff --git a/packages/client/src/__tests__/utils.test.ts b/packages/client/src/__tests__/utils.test.ts index 902e7d73..36a6e032 100644 --- a/packages/client/src/__tests__/utils.test.ts +++ b/packages/client/src/__tests__/utils.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import { createHandler, normalizeMode, + normalizeModelParameters, numberOrZero, parseJSONWithPossibleFences, parseTemplate, parseUsage, + pickForwardedModelParameters, setLdSpanAttributes, setUsageSpanAttributes, } from '../utils.js'; @@ -621,3 +623,62 @@ describe('setUsageSpanAttributes', () => { }); }); }); + +// ─── pickForwardedModelParameters ───────────────────────────────────────────── + +describe('pickForwardedModelParameters', () => { + const KEYS = ['temperature', 'top_p', 'maxTurns'] as const; + + it('returns {} when parameters is undefined', () => { + expect(pickForwardedModelParameters(undefined, KEYS)).toEqual({}); + }); + + it('returns {} when parameters sets nothing in the allowlist', () => { + expect(pickForwardedModelParameters({ unrelated: 'x' }, KEYS)).toEqual({}); + }); + + it('picks only the allowlisted keys, preserving their values', () => { + expect(pickForwardedModelParameters({ temperature: 0.5, unrelated: 'x', maxTurns: 3 }, KEYS)).toEqual({ + temperature: 0.5, + maxTurns: 3, + }); + }); + + it('drops a key whose value is explicitly undefined', () => { + expect(pickForwardedModelParameters({ temperature: undefined, top_p: 0.9 }, KEYS)).toEqual({ + top_p: 0.9, + }); + }); + + it('passes through falsy-but-defined values, including 0 and false', () => { + expect(pickForwardedModelParameters({ temperature: 0, maxTurns: false }, KEYS)).toEqual({ + temperature: 0, + maxTurns: false, + }); + }); +}); + +// ─── normalizeModelParameters ────────────────────────────────────────────────── + +describe('normalizeModelParameters', () => { + it('returns {} for undefined', () => { + expect(normalizeModelParameters(undefined)).toEqual({}); + }); + + it('returns {} for null', () => { + expect(normalizeModelParameters(null)).toEqual({}); + }); + + it('returns {} for a non-object value', () => { + expect(normalizeModelParameters('not an object')).toEqual({}); + }); + + it('returns the object unchanged when it is a usable object', () => { + const parameters = { temperature: 0.7, thinking: { type: 'enabled' } }; + expect(normalizeModelParameters(parameters)).toBe(parameters); + }); + + it('returns an empty array unchanged (arrays are objects)', () => { + expect(normalizeModelParameters([])).toEqual([]); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9e5ee628..4e2f66cd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -89,9 +89,11 @@ export { createRunUsage, endSpanOnce, langChainSpanUsage, + normalizeModelParameters, omitModelStamps, parseJSONWithPossibleFences, parseTemplate, + pickForwardedModelParameters, setLdSpanAttributes, setModelIdentityAttributes, setUsageSpanAttributes, diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 7afc6d1c..304ac615 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -563,6 +563,50 @@ export function parseJSONWithPossibleFences(rawText: string): T | null { return null; } +/** + * Picks the subset of `config.model.parameters` whose keys appear in `keys`, unchanged + * otherwise: a config that sets nothing in `keys` produces `{}`, so the provider call sees + * exactly what it always has. + * + * For handlers whose provider request type is **closed** — an unknown field is a runtime + * error to the SDK, not a harmless extra — rather than open-ended. Those handlers + * (`claude-agents`, `claude-messages`, `openai-messages`, `openai-agents`) each keep their own + * `FORWARDED_*_KEYS` allowlist next to the request shape it maps onto; this helper only owns the + * picking loop the four of them used to repeat. `openai-agents` additionally collapses an empty + * result to `undefined` itself, since only that caller needs the Agent constructed exactly as it + * was before this helper existed — that collapsing is not this function's job. + * + * LangChain's handlers are the opposite case — the chat model takes an open-ended options bag, so + * narrowing it to a key list would silently drop settings that work today. They use + * `normalizeModelParameters` instead, which forwards the whole object. + */ +export function pickForwardedModelParameters( + parameters: Record | undefined, + keys: ReadonlyArray, +): Record { + const picked: Record = {}; + if (!parameters) return picked; + for (const key of keys) { + if (parameters[key] !== undefined) picked[key] = parameters[key]; + } + return picked; +} + +/** + * Returns `parameters` unchanged when it is a usable object, `{}` otherwise. + * + * The LangChain handlers spread the *entire* `model.parameters` bag into the chat model + * constructor rather than picking an allowlist — LangChain's chat models take an open-ended + * options bag, so narrowing it here would silently drop customer settings that work today. This + * is the defensive guard three call sites (`langchain-messages`, `langchain-agents`, and its + * per-node native graph model factory) used to repeat inline: `model.parameters` is optional on + * `AiConfigRep`, and a config's own `model` field is technically reachable as `unknown` at these + * call sites, so both must be checked before spreading. + */ +export function normalizeModelParameters(parameters: unknown): Record { + return parameters && typeof parameters === 'object' ? (parameters as Record) : {}; +} + /** * Returns a copy of `trackData` without `modelKey` / `modelVersion`. Used when * overlaying a judge's `trackData` on its parent's so a judge without a pinned diff --git a/packages/langchain-agents/src/handler.ts b/packages/langchain-agents/src/handler.ts index 98650611..52b5ae51 100644 --- a/packages/langchain-agents/src/handler.ts +++ b/packages/langchain-agents/src/handler.ts @@ -17,6 +17,7 @@ import { langChainSpanMessages, langChainSpanUsage, type Message, + normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanUsage, @@ -260,9 +261,7 @@ export function buildSpanCallbacks( export type LangChainModelSource = BaseChatModel | ((config: AiConfigRep) => BaseChatModel | Promise); function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { - ...(config.model?.parameters && typeof config.model.parameters === 'object' ? config.model.parameters : {}), - }; + const parameters = { ...normalizeModelParameters(config.model?.parameters) }; if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; return { ...parameters, model: resolvedModelName(config, fallbackName) }; } diff --git a/packages/langchain-agents/src/native-graph.ts b/packages/langchain-agents/src/native-graph.ts index 1b2781b1..d55b11ce 100644 --- a/packages/langchain-agents/src/native-graph.ts +++ b/packages/langchain-agents/src/native-graph.ts @@ -13,6 +13,7 @@ import { type Message, makeNodeTrackData, type NativeTool, + normalizeModelParameters, type ProviderGraphResponse, parseTemplate, type ToolHandlerFn, @@ -144,9 +145,7 @@ export const toLangGraph = ( opts?.modelFactory ?? ((node) => new ChatOpenAI({ - ...(node.config.model.parameters && typeof node.config.model.parameters === 'object' - ? node.config.model.parameters - : {}), + ...normalizeModelParameters(node.config.model.parameters), model: node.config.model.name, })); const ldContext = opts?.context; diff --git a/packages/langchain-messages/src/handler.ts b/packages/langchain-messages/src/handler.ts index 6abb89d0..8480f40c 100644 --- a/packages/langchain-messages/src/handler.ts +++ b/packages/langchain-messages/src/handler.ts @@ -19,6 +19,7 @@ import { type Message, type MessageContent, type NativeTool, + normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanUsage, @@ -170,9 +171,7 @@ function normalizeOutputSchema(schema: Record): Record BaseChatModel | Promise); function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { - ...(config.model?.parameters && typeof config.model.parameters === 'object' ? config.model.parameters : {}), - }; + const parameters = { ...normalizeModelParameters(config.model?.parameters) }; if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; // Name from the config always wins over a colliding `model` key in the parameter bag. return { ...parameters, model: resolvedModelName(config, fallbackName) }; diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index d94bac82..471723ce 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -15,6 +15,7 @@ import { type NativeTool, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, type SpanUsage, @@ -524,11 +525,7 @@ const FORWARDED_MODEL_SETTINGS_KEYS = [ * produces `undefined`, so the Agent is constructed exactly as it always has been. */ function buildModelSettings(parameters: AiConfigRep['model']['parameters']): ModelSettings | undefined { - if (!parameters) return undefined; - const settings: Record = {}; - for (const key of FORWARDED_MODEL_SETTINGS_KEYS) { - if (parameters[key] !== undefined) settings[key] = parameters[key]; - } + const settings = pickForwardedModelParameters(parameters, FORWARDED_MODEL_SETTINGS_KEYS); return Object.keys(settings).length > 0 ? (settings as ModelSettings) : undefined; } diff --git a/packages/openai-messages/src/handler.ts b/packages/openai-messages/src/handler.ts index e908b3d6..45075fce 100644 --- a/packages/openai-messages/src/handler.ts +++ b/packages/openai-messages/src/handler.ts @@ -14,6 +14,7 @@ import { type Message, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, type SpanUsage, @@ -359,12 +360,7 @@ const FORWARDED_MODEL_PARAMETER_KEYS = [ * `{}`, so the request sent is byte-for-byte what it always was. */ function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - if (!parameters) return {}; - const forwarded: Record = {}; - for (const key of FORWARDED_MODEL_PARAMETER_KEYS) { - if (parameters[key] !== undefined) forwarded[key] = parameters[key]; - } - return forwarded; + return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); } export function createOpenAIHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { From cf9a611063f33922223b72d6d7935053dd99a7f5 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 13:02:38 -0400 Subject: [PATCH 06/23] refactor(handlers): drop model.parameters allowlists, pass the whole bag through The four allowlist handlers (claude-agents, claude-messages, openai-messages, openai-agents) each maintained a hand-picked FORWARDED_*_KEYS list and a pickForwardedModelParameters call to filter config.model.parameters before forwarding it to the provider SDK. The allowlists silently dropped valid settings and needed updating whenever a provider added a new tunable. The LaunchDarkly UI already constrains which keys a customer can save, so an unsupported key reaching the provider is an acceptable error, not something this SDK needs to guard. Replace every allowlist with normalizeModelParameters, the same pass-through helper the LangChain handlers already use, and delete pickForwardedModelParameters from @launchdarkly/ai-server now that no caller is left. While making every call site spread parameters first, found three places where a handler-owned key was set with a conditional spread (`...(cond ? { key } : {})`) instead of an explicit key: when the handler's own condition was false, the key was never set, so a customer's model.parameters could smuggle a value through unopposed. Fixed by always setting the key explicitly, to undefined when absent: claude-messages' `system`/`tools`, claude-agents' `systemPrompt`, and openai-messages' `tools`/`previous_response_id`/`text` (structured output). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/handler.test.ts | 48 +++++++++++++++-- packages/claude-agents/src/handler.ts | 37 +++---------- .../src/__tests__/handler.test.ts | 10 ++-- packages/claude-messages/src/handler.ts | 45 +++------------- packages/client/src/__tests__/utils.test.ts | 35 ------------- packages/client/src/index.ts | 1 - packages/client/src/utils.ts | 43 +++------------ .../src/__tests__/handler.test.ts | 14 +++-- packages/openai-agents/src/handler.ts | 32 ++---------- .../src/__tests__/handler.test.ts | 8 +-- packages/openai-messages/src/handler.ts | 52 +++++-------------- 11 files changed, 105 insertions(+), 220 deletions(-) diff --git a/packages/claude-agents/src/__tests__/handler.test.ts b/packages/claude-agents/src/__tests__/handler.test.ts index c33e2520..40cec777 100644 --- a/packages/claude-agents/src/__tests__/handler.test.ts +++ b/packages/claude-agents/src/__tests__/handler.test.ts @@ -214,7 +214,9 @@ describe('createClaudeAgentsHandler', () => { expect(options.fallbackModel).toBe('claude-haiku'); }); - it('ignores unrecognized model.parameters keys rather than forwarding them', async () => { + it('forwards unrecognized model.parameters keys through to query options rather than dropping them', async () => { + // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key + // reaching query() is expected to surface as a CLI/provider error, not be silently dropped. mockQuery.mockImplementation(makeResultMessage()); const config = { ...baseConfig, @@ -222,8 +224,34 @@ describe('createClaudeAgentsHandler', () => { }; await createClaudeAgentsHandler()(config as any, 'q'); const { options } = mockQuery.mock.calls[0][0]; - expect(options.temperature).toBeUndefined(); - expect(options.max_tokens).toBeUndefined(); + expect(options.temperature).toBe(0.7); + expect(options.max_tokens).toBe(100); + }); + + it('does not let model.parameters override model, tools, allowedTools, mcpServers, hooks, or systemPrompt', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + model: 'smuggled-model', + tools: ['smuggled-tool'], + allowedTools: ['smuggled-allowed'], + mcpServers: { smuggled: true }, + hooks: { smuggled: true }, + systemPrompt: 'smuggled prompt', + }, + }, + }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.model).toBe('claude-opus-4-5'); + expect(options.systemPrompt).toBe('You are helpful.'); + expect(options.tools).not.toEqual(['smuggled-tool']); + expect(options.allowedTools).not.toEqual(['smuggled-allowed']); + expect(options.mcpServers).not.toEqual({ smuggled: true }); + expect(options.hooks).not.toEqual({ smuggled: true }); }); it('does not set maxTurns or other forwarded keys when model.parameters is absent', async () => { @@ -553,6 +581,20 @@ describe('createClaudeAgentsHandler', () => { expect(options.includePartialMessages).toBe(true); }); + it('does not let model.parameters override includePartialMessages on the streaming path', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', subtype: 'success', result: 'done', usage: {} }; + }); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { includePartialMessages: false } }, + }; + const handler = createClaudeAgentsHandler(); + await collectStream(handler.stream?.(config as any, 'q', {}, {})); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.includePartialMessages).toBe(true); + }); + it('sets span attributes and puts content on attributes when enabled', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', subtype: 'success', result: 'ok', usage: { input_tokens: 2, output_tokens: 1 } }; diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index f3f0dd2f..8d06962f 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -15,9 +15,9 @@ import { type MessageContent, NATIVE_TOOL_KEY, type NativeTool, + normalizeModelParameters, type ProviderHandler, parseTemplate, - pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, setConversationIdIfAbsent, @@ -883,32 +883,6 @@ function buildOpeningMessages( return turns.map((turn) => ({ role: turn.role, parts: turnToSpanParts(turn.content) })); } -/** - * `Options` keys this handler forwards verbatim from `config.model.parameters`, so an AI Config can - * cap turns or tune agent-run behaviour without a code change here. Anthropic's raw Messages API - * knobs (`temperature`, `max_tokens`, …) have no equivalent on this SDK's `Options` — the Claude - * Agent SDK runs the CLI, not a completions call — so keys outside this list are ignored rather - * than spread through, per the contract that unrecognized `model.parameters` keys are dropped, not - * forwarded to a shape the SDK does not accept. - */ -const FORWARDED_MODEL_PARAMETER_KEYS = [ - 'maxTurns', - 'maxThinkingTokens', - 'maxBudgetUsd', - 'effort', - 'fallbackModel', - 'thinking', -] as const; - -/** - * Picks the subset of `config.model.parameters` that maps onto `query()`'s `Options` type, - * unchanged otherwise: no default turn cap, no default budget — a config that sets nothing here - * produces `{}`, so `query()` sees exactly what it always has. - */ -function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); -} - function buildQueryOptions( config: AiConfigRep, prompt: string | AsyncIterable, @@ -925,13 +899,14 @@ function buildQueryOptions( return { prompt, options: { + // Spread first: every key below is handler-owned and must win over a config value. + ...extra, model: config.model.name, tools: nativeToolNames.length > 0 ? nativeToolNames : [], allowedTools: allAllowedTools.length > 0 ? allAllowedTools : undefined, mcpServers: toolMCP ? { [TOOL_MCP_NAME]: toolMCP } : undefined, hooks, - ...(systemPrompt ? { systemPrompt } : {}), - ...extra, + systemPrompt, }, }; } @@ -1008,7 +983,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - buildModelParameterOptions(config.model.parameters), + normalizeModelParameters(config.model.parameters), ), )) { recordConversationId(span, message); @@ -1136,7 +1111,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap toolMCP, toolTelemetry?.hooks, { - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), includePartialMessages: true, }, ), diff --git a/packages/claude-messages/src/__tests__/handler.test.ts b/packages/claude-messages/src/__tests__/handler.test.ts index d4176af6..9456b99f 100644 --- a/packages/claude-messages/src/__tests__/handler.test.ts +++ b/packages/claude-messages/src/__tests__/handler.test.ts @@ -1342,12 +1342,14 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(call.tools).toBeUndefined(); }); - it('drops unknown model.parameters keys', async () => { + it('forwards an unrecognized model.parameters key rather than dropping it', async () => { + // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key + // reaching the Anthropic SDK is expected to surface as a provider error, not be silently dropped. mockMessagesCreate.mockResolvedValue(mockFinalResponse()); const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { made_up_key: 'nope' } } }; const handler = createClaudeMessagesHandler(); await handler(cfg as any, 'hi'); - expect(mockMessagesCreate.mock.calls[0][0]).not.toHaveProperty('made_up_key'); + expect(mockMessagesCreate.mock.calls[0][0]).toHaveProperty('made_up_key', 'nope'); }); it('leaves the call unchanged from today when model.parameters is absent', async () => { @@ -1397,7 +1399,7 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(call.top_k).toBe(10); }); - it('streaming path still defaults max_tokens to 1024 and drops unknown keys', async () => { + it('streaming path still defaults max_tokens to 1024 and forwards an unrecognized key', async () => { const finalMsg = { usage: { input_tokens: 3, output_tokens: 7 }, stop_reason: 'end_turn', @@ -1412,7 +1414,7 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { } const call = mockMessagesStream.mock.calls[0][0]; expect(call.max_tokens).toBe(1024); - expect(call).not.toHaveProperty('made_up_key'); + expect(call).toHaveProperty('made_up_key', 'nope'); }); it('streaming path does not let model.parameters override model/messages/system', async () => { diff --git a/packages/claude-messages/src/handler.ts b/packages/claude-messages/src/handler.ts index de7b0128..64470661 100644 --- a/packages/claude-messages/src/handler.ts +++ b/packages/claude-messages/src/handler.ts @@ -13,9 +13,9 @@ import { type Message, type MessageContent, type NativeTool, + normalizeModelParameters, type ProviderHandler, parseTemplate, - pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, setInputContentAttributes, @@ -330,37 +330,6 @@ const buildMessages = ( return { messages, system }; }; -/** - * `MessageCreateParamsBase` keys this handler forwards verbatim from `config.model.parameters`, - * beyond `max_tokens`, which is handled separately because it carries a default. Excludes - * `model`, `messages`, `tools`, `system`, `max_tokens`, and `stream`, which this handler sets - * itself from the config and the call shape — a `model.parameters` value for any of those must - * not be able to override what the handler already decided. - */ -const FORWARDED_MODEL_PARAMETER_KEYS = [ - 'cache_control', - 'container', - 'inference_geo', - 'metadata', - 'output_config', - 'service_tier', - 'stop_sequences', - 'temperature', - 'thinking', - 'tool_choice', - 'top_k', - 'top_p', -] as const; - -/** - * Picks the subset of `config.model.parameters` that maps onto `MessageCreateParamsBase`, - * unchanged otherwise: a config that sets nothing here produces `{}`, so the provider call sees - * exactly what it always has. - */ -function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); -} - const MAX_STEPS = 10; export function createClaudeMessagesHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { @@ -398,12 +367,12 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let response: Anthropic.Message; try { response = await anthropic.messages.create({ - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, max_tokens: maxTokens, - ...(system ? { system } : {}), + system, messages: conversation, - ...(tools.length > 0 ? { tools } : {}), + tools: tools.length > 0 ? tools : undefined, }); } catch (err) { failSpan(modelSpan, err); @@ -559,12 +528,12 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let finalMsg: Anthropic.Message; try { const stream = anthropic.messages.stream({ - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, max_tokens: maxTokens, - ...(system ? { system } : {}), + system, messages: conversation, - ...(tools.length > 0 ? { tools } : {}), + tools: tools.length > 0 ? tools : undefined, }); // Yield text deltas for this turn diff --git a/packages/client/src/__tests__/utils.test.ts b/packages/client/src/__tests__/utils.test.ts index 36a6e032..8bdd3d92 100644 --- a/packages/client/src/__tests__/utils.test.ts +++ b/packages/client/src/__tests__/utils.test.ts @@ -7,7 +7,6 @@ import { parseJSONWithPossibleFences, parseTemplate, parseUsage, - pickForwardedModelParameters, setLdSpanAttributes, setUsageSpanAttributes, } from '../utils.js'; @@ -624,40 +623,6 @@ describe('setUsageSpanAttributes', () => { }); }); -// ─── pickForwardedModelParameters ───────────────────────────────────────────── - -describe('pickForwardedModelParameters', () => { - const KEYS = ['temperature', 'top_p', 'maxTurns'] as const; - - it('returns {} when parameters is undefined', () => { - expect(pickForwardedModelParameters(undefined, KEYS)).toEqual({}); - }); - - it('returns {} when parameters sets nothing in the allowlist', () => { - expect(pickForwardedModelParameters({ unrelated: 'x' }, KEYS)).toEqual({}); - }); - - it('picks only the allowlisted keys, preserving their values', () => { - expect(pickForwardedModelParameters({ temperature: 0.5, unrelated: 'x', maxTurns: 3 }, KEYS)).toEqual({ - temperature: 0.5, - maxTurns: 3, - }); - }); - - it('drops a key whose value is explicitly undefined', () => { - expect(pickForwardedModelParameters({ temperature: undefined, top_p: 0.9 }, KEYS)).toEqual({ - top_p: 0.9, - }); - }); - - it('passes through falsy-but-defined values, including 0 and false', () => { - expect(pickForwardedModelParameters({ temperature: 0, maxTurns: false }, KEYS)).toEqual({ - temperature: 0, - maxTurns: false, - }); - }); -}); - // ─── normalizeModelParameters ────────────────────────────────────────────────── describe('normalizeModelParameters', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4e2f66cd..ac3d978d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -93,7 +93,6 @@ export { omitModelStamps, parseJSONWithPossibleFences, parseTemplate, - pickForwardedModelParameters, setLdSpanAttributes, setModelIdentityAttributes, setUsageSpanAttributes, diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 304ac615..79bea9b6 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -563,45 +563,16 @@ export function parseJSONWithPossibleFences(rawText: string): T | null { return null; } -/** - * Picks the subset of `config.model.parameters` whose keys appear in `keys`, unchanged - * otherwise: a config that sets nothing in `keys` produces `{}`, so the provider call sees - * exactly what it always has. - * - * For handlers whose provider request type is **closed** — an unknown field is a runtime - * error to the SDK, not a harmless extra — rather than open-ended. Those handlers - * (`claude-agents`, `claude-messages`, `openai-messages`, `openai-agents`) each keep their own - * `FORWARDED_*_KEYS` allowlist next to the request shape it maps onto; this helper only owns the - * picking loop the four of them used to repeat. `openai-agents` additionally collapses an empty - * result to `undefined` itself, since only that caller needs the Agent constructed exactly as it - * was before this helper existed — that collapsing is not this function's job. - * - * LangChain's handlers are the opposite case — the chat model takes an open-ended options bag, so - * narrowing it to a key list would silently drop settings that work today. They use - * `normalizeModelParameters` instead, which forwards the whole object. - */ -export function pickForwardedModelParameters( - parameters: Record | undefined, - keys: ReadonlyArray, -): Record { - const picked: Record = {}; - if (!parameters) return picked; - for (const key of keys) { - if (parameters[key] !== undefined) picked[key] = parameters[key]; - } - return picked; -} - /** * Returns `parameters` unchanged when it is a usable object, `{}` otherwise. * - * The LangChain handlers spread the *entire* `model.parameters` bag into the chat model - * constructor rather than picking an allowlist — LangChain's chat models take an open-ended - * options bag, so narrowing it here would silently drop customer settings that work today. This - * is the defensive guard three call sites (`langchain-messages`, `langchain-agents`, and its - * per-node native graph model factory) used to repeat inline: `model.parameters` is optional on - * `AiConfigRep`, and a config's own `model` field is technically reachable as `unknown` at these - * call sites, so both must be checked before spreading. + * Every handler spreads the *entire* `model.parameters` bag straight into its provider call + * rather than picking an allowlist. The LaunchDarkly UI already constrains which keys a customer + * can save, and an unsupported key reaching the provider is expected to surface as a provider + * error rather than being silently dropped here. This is the defensive guard the call sites used + * to repeat inline: `model.parameters` is optional on `AiConfigRep`, and a config's own `model` + * field is technically reachable as `unknown` at these call sites, so both must be checked before + * spreading. */ export function normalizeModelParameters(parameters: unknown): Record { return parameters && typeof parameters === 'object' ? (parameters as Record) : {}; diff --git a/packages/openai-agents/src/__tests__/handler.test.ts b/packages/openai-agents/src/__tests__/handler.test.ts index 740d25c3..83c23380 100644 --- a/packages/openai-agents/src/__tests__/handler.test.ts +++ b/packages/openai-agents/src/__tests__/handler.test.ts @@ -249,7 +249,9 @@ describe('createOpenAIAgentHandler', () => { }); }); - it('ignores unrecognized model.parameters keys rather than forwarding them', async () => { + it('forwards unrecognized model.parameters keys through to modelSettings rather than dropping them', async () => { + // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key + // reaching the Agents SDK is expected to surface as a provider error, not be silently dropped. mockRun.mockResolvedValue(mockRunResult()); const config = { ...baseConfig, @@ -257,7 +259,7 @@ describe('createOpenAIAgentHandler', () => { }; await createOpenAIAgentHandler()(config as any, 'q'); const agentArgs = mockAgentConstructor.mock.calls[0][0]; - expect(agentArgs.modelSettings).toBeUndefined(); + expect(agentArgs.modelSettings).toEqual({ someUnknownKey: 'nope', anotherOne: 42 }); }); it('does not set modelSettings or maxTurns when model.parameters is absent', async () => { @@ -623,7 +625,13 @@ describe('createOpenAIAgentHandler', () => { }; const handler = createOpenAIAgentHandler(); await collectStream(handler.stream?.(config as any, 'q', {}, {})); - expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ temperature: 0.5 }); + // `maxTurns` is read separately for Runner.run, but the whole bag (including it) also passes + // straight through to modelSettings now that there is no allowlist filtering it out. + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ + temperature: 0.5, + maxTurns: 5, + unknownKey: 'nope', + }); const runOptions = mockRun.mock.calls[0][2]; expect(runOptions).toMatchObject({ maxTurns: 5 }); }); diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index 471723ce..5c5021c9 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -13,9 +13,9 @@ import { type Message, type MessageContent, type NativeTool, + normalizeModelParameters, type ProviderHandler, parseTemplate, - pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, type SpanUsage, @@ -498,34 +498,12 @@ function configConversationTurns(config: AiConfigRep, variables: Record; - -/** - * Picks the subset of `config.model.parameters` that maps onto the Agents SDK's `ModelSettings`, - * unchanged otherwise: no default temperature, no default cap — a config that sets nothing here - * produces `undefined`, so the Agent is constructed exactly as it always has been. + * `config.model.parameters`, forwarded verbatim as the Agents SDK's `ModelSettings`, unchanged + * otherwise: no default temperature, no default cap — a config that sets nothing here produces + * `undefined`, so the Agent is constructed exactly as it always has been. */ function buildModelSettings(parameters: AiConfigRep['model']['parameters']): ModelSettings | undefined { - const settings = pickForwardedModelParameters(parameters, FORWARDED_MODEL_SETTINGS_KEYS); + const settings = normalizeModelParameters(parameters); return Object.keys(settings).length > 0 ? (settings as ModelSettings) : undefined; } diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index 6ed4f5dc..3fec3cfb 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -319,7 +319,9 @@ describe('createOpenAIHandler', () => { expect(call.max_output_tokens).toBe(256); }); - it('drops unrecognized model.parameters keys rather than forwarding them', async () => { + it('forwards unrecognized model.parameters keys through to the provider call rather than dropping them', async () => { + // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key + // reaching the Responses API is expected to surface as a provider error, not be silently dropped. mockResponsesCreate.mockResolvedValue(mockFinalResponse()); const config = { ...baseConfig, @@ -327,8 +329,8 @@ describe('createOpenAIHandler', () => { }; await createOpenAIHandler()(config as any, 'q'); const call = mockResponsesCreate.mock.calls[0][0]; - expect(call.max_tokens).toBeUndefined(); - expect(call.foo).toBeUndefined(); + expect(call.max_tokens).toBe(999); + expect(call.foo).toBe('bar'); }); it('does not let model.parameters override model, input, tools, or previous_response_id', async () => { diff --git a/packages/openai-messages/src/handler.ts b/packages/openai-messages/src/handler.ts index 45075fce..65a3e715 100644 --- a/packages/openai-messages/src/handler.ts +++ b/packages/openai-messages/src/handler.ts @@ -12,9 +12,9 @@ import { isContentBlocks, type LDContext, type Message, + normalizeModelParameters, type ProviderHandler, parseTemplate, - pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, type SpanUsage, @@ -332,37 +332,6 @@ const jsonSchemaFormat = (schema: Record): any => ({ const toToolDefinitions = (tools: FunctionTool[]): ToolDefinitionInput[] => tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters })); -/** - * `ResponseCreateParams` keys this handler forwards verbatim from `config.model.parameters`, so an - * AI Config can tune sampling and token limits without a code change here. Kept to an explicit - * allowlist, not a spread, because the Responses API's request type is closed and a customer must - * not be able to smuggle `model`, `input`, `tools`, or `previous_response_id` back in through - * `model.parameters` and quietly override the values this handler computes itself. - */ -const FORWARDED_MODEL_PARAMETER_KEYS = [ - 'temperature', - 'top_p', - 'max_output_tokens', - 'top_logprobs', - 'parallel_tool_calls', - 'reasoning', - 'truncation', - 'metadata', - 'store', - 'service_tier', - 'prompt_cache_key', - 'safety_identifier', -] as const; - -/** - * Picks the subset of `config.model.parameters` that maps onto `ResponseCreateParams`, unchanged - * otherwise: no default temperature, no default cap. A config that sets nothing here produces - * `{}`, so the request sent is byte-for-byte what it always was. - */ -function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - return pickForwardedModelParameters(parameters, FORWARDED_MODEL_PARAMETER_KEYS); -} - export function createOpenAIHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { const openai = new OpenAI(); @@ -435,11 +404,12 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp let response = await runModelTurn( { - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, input: inputMessages, - ...(tools.length > 0 ? { tools } : {}), - ...(config.outputFormat ? { text: { format: jsonSchemaFormat(config.outputFormat) } } : {}), + tools: tools.length > 0 ? tools : undefined, + previous_response_id: undefined, + text: config.outputFormat ? { format: jsonSchemaFormat(config.outputFormat) } : undefined, }, toolDefinitions, ); @@ -478,10 +448,12 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp response = await runModelTurn( { - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, previous_response_id: response.id, input: toolOutputs, + tools: undefined, + text: undefined, }, toolDefinitions, ); @@ -561,16 +533,18 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp try { const streamParams = previousResponseId ? { - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, previous_response_id: previousResponseId, input: currentInput, + tools: undefined, } : { - ...buildModelParameterOptions(config.model.parameters), + ...normalizeModelParameters(config.model.parameters), model: config.model.name, input: currentInput, - ...(tools.length > 0 ? { tools } : {}), + tools: tools.length > 0 ? tools : undefined, + previous_response_id: undefined, }; // biome-ignore lint/suspicious/noExplicitAny: streamParams union type does not match the SDK's overloaded stream() signature From 0c3684015d2be55ca4080aa600e9549eb7c87212 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Tue, 22 Sep 2026 13:34:21 -0400 Subject: [PATCH 07/23] refactor(claude-agents): pass model parameters as an explicit argument buildQueryOptions took a single `extra` bag that carried both the customer's model.parameters and this handler's own internal flags (includePartialMessages). Two different trust levels in one untyped argument, with the safe ordering only holding by accident of how the call site happened to build the literal. Split them: `modelParameters` is required, documented as untrusted, and spread first so every handler-owned key overrides it. `internalOptions` carries the handler's own flags and is spread last so it wins over everything. A reader of the signature can now see which is which. Co-Authored-By: Claude Opus 5 (1M context) --- packages/claude-agents/src/handler.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index 8d06962f..3aa91ce3 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -893,20 +893,29 @@ function buildQueryOptions( toolMCP: any, // biome-ignore lint/suspicious/noExplicitAny: Claude SDK hooks config type is not publicly exported hooks: any, - extra?: Record, + /** + * The customer's `model.parameters`, forwarded verbatim. Untrusted: it is whatever was + * saved on the AI Config, so it is spread FIRST and every handler-owned key below + * overrides it. Passed explicitly rather than folded into `internalOptions` so the two + * trust levels cannot be confused at a call site. + */ + modelParameters: Record, + /** Options this handler sets for itself, e.g. `includePartialMessages` on the streaming + * path. Trusted, so these are spread LAST and win over everything. */ + internalOptions?: Record, ) { const allAllowedTools = [...mcpAllowedTools, ...nativeToolNames]; return { prompt, options: { - // Spread first: every key below is handler-owned and must win over a config value. - ...extra, + ...modelParameters, model: config.model.name, tools: nativeToolNames.length > 0 ? nativeToolNames : [], allowedTools: allAllowedTools.length > 0 ? allAllowedTools : undefined, mcpServers: toolMCP ? { [TOOL_MCP_NAME]: toolMCP } : undefined, hooks, systemPrompt, + ...internalOptions, }, }; } @@ -1110,10 +1119,8 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - { - ...normalizeModelParameters(config.model.parameters), - includePartialMessages: true, - }, + normalizeModelParameters(config.model.parameters), + { includePartialMessages: true }, ), )) { recordConversationId(span, message); From 75c6287ebdc4098df6722dfc76656b068e9a3985 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:03:39 -0400 Subject: [PATCH 08/23] fix(handlers): camelize snake_case model.parameters for framework handlers The LaunchDarkly UI saves model.parameters keys in snake_case, the wire name every provider SDK expects (max_turns, top_p, ...). claude-agents, openai-agents, and both langchain handlers wrap JavaScript frameworks that only read camelCase option names, so a snake_case key like max_turns silently vanished instead of reaching the framework: no error, the value just never took effect. Add camelizeModelParameters (packages/client/src/utils.ts), converting top-level snake_case keys to camelCase generated from the key itself. Nested values (e.g. thinking.budget_tokens) pass through untouched, and when both spellings of a key are set the snake_case one wins, deterministically regardless of key order. Apply it at every call site in the four affected handlers: claude-agents (query() options, both the blocking and streaming paths), openai-agents (ModelSettings and buildMaxTurns for Runner.run), and both langchain handlers' default model constructors, including langchain-agents' native-graph.ts. claude-messages and openai-messages wrap raw provider clients that already read snake_case themselves, so they are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/handler.test.ts | 46 +++++++++++- packages/claude-agents/src/handler.ts | 5 +- .../src/__tests__/handler.test.ts | 19 +++++ packages/client/src/__tests__/utils.test.ts | 74 +++++++++++++++++++ packages/client/src/index.ts | 1 + packages/client/src/utils.ts | 55 ++++++++++++++ .../src/__tests__/handler.test.ts | 22 +++++- .../src/__tests__/native-graph.test.ts | 16 +++- .../src/__tests__/wire.test.ts | 53 +++++++++++++ packages/langchain-agents/src/handler.ts | 3 +- packages/langchain-agents/src/native-graph.ts | 3 +- .../src/__tests__/handler.test.ts | 20 ++++- .../src/__tests__/wire.test.ts | 53 +++++++++++++ packages/langchain-messages/src/handler.ts | 3 +- .../src/__tests__/handler.test.ts | 31 ++++++++ packages/openai-agents/src/handler.ts | 8 +- .../src/__tests__/handler.test.ts | 17 +++++ 17 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 packages/langchain-agents/src/__tests__/wire.test.ts create mode 100644 packages/langchain-messages/src/__tests__/wire.test.ts diff --git a/packages/claude-agents/src/__tests__/handler.test.ts b/packages/claude-agents/src/__tests__/handler.test.ts index 40cec777..f0f5f357 100644 --- a/packages/claude-agents/src/__tests__/handler.test.ts +++ b/packages/claude-agents/src/__tests__/handler.test.ts @@ -197,6 +197,34 @@ describe('createClaudeAgentsHandler', () => { expect(options.maxTurns).toBe(3); }); + // The LaunchDarkly UI writes model.parameters in snake_case (the wire name every provider + // client expects), but the Claude Agent SDK's query() Options only reads camelCase — so a + // config saved as `max_turns` must reach query() as `maxTurns`, not vanish silently. + it('converts a snake_case max_turns to maxTurns for query options', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_turns: 7 } } }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxTurns).toBe(7); + expect(options.max_turns).toBeUndefined(); + }); + + it('converts snake_case model.parameters keys for query options (maxThinkingTokens etc.)', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { max_thinking_tokens: 2048, max_budget_usd: 1.5, fallback_model: 'claude-haiku' }, + }, + }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxThinkingTokens).toBe(2048); + expect(options.maxBudgetUsd).toBe(1.5); + expect(options.fallbackModel).toBe('claude-haiku'); + }); + it('forwards recognized model.parameters keys to query options', async () => { mockQuery.mockImplementation(makeResultMessage()); const config = { @@ -217,15 +245,17 @@ describe('createClaudeAgentsHandler', () => { it('forwards unrecognized model.parameters keys through to query options rather than dropping them', async () => { // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key // reaching query() is expected to surface as a CLI/provider error, not be silently dropped. + // The key here has no underscore, so the snake_case->camelCase conversion this handler now + // applies leaves it alone — this test is about the allowlist, not casing. mockQuery.mockImplementation(makeResultMessage()); const config = { ...baseConfig, - model: { ...baseConfig.model, parameters: { temperature: 0.7, max_tokens: 100 } }, + model: { ...baseConfig.model, parameters: { temperature: 0.7, someUnknownKey: 100 } }, }; await createClaudeAgentsHandler()(config as any, 'q'); const { options } = mockQuery.mock.calls[0][0]; expect(options.temperature).toBe(0.7); - expect(options.max_tokens).toBe(100); + expect(options.someUnknownKey).toBe(100); }); it('does not let model.parameters override model, tools, allowedTools, mcpServers, hooks, or systemPrompt', async () => { @@ -581,6 +611,18 @@ describe('createClaudeAgentsHandler', () => { expect(options.includePartialMessages).toBe(true); }); + it('converts a snake_case max_turns to maxTurns on the streaming path', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', subtype: 'success', result: 'done', usage: {} }; + }); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_turns: 9 } } }; + const handler = createClaudeAgentsHandler(); + await collectStream(handler.stream?.(config as any, 'q', {}, {})); + const { options } = mockQuery.mock.calls[0][0]; + expect(options.maxTurns).toBe(9); + expect(options.max_turns).toBeUndefined(); + }); + it('does not let model.parameters override includePartialMessages on the streaming path', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', subtype: 'success', result: 'done', usage: {} }; diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index 3aa91ce3..34421cd8 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -5,6 +5,7 @@ import { type CanonicalTurn, type ConfigTurn, type ContentCaptureOptions, + camelizeModelParameters, composeHistory, config, contentToText, @@ -992,7 +993,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - normalizeModelParameters(config.model.parameters), + camelizeModelParameters(normalizeModelParameters(config.model.parameters)), ), )) { recordConversationId(span, message); @@ -1119,7 +1120,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - normalizeModelParameters(config.model.parameters), + camelizeModelParameters(normalizeModelParameters(config.model.parameters)), { includePartialMessages: true }, ), )) { diff --git a/packages/claude-messages/src/__tests__/handler.test.ts b/packages/claude-messages/src/__tests__/handler.test.ts index 9456b99f..0a1fa21b 100644 --- a/packages/claude-messages/src/__tests__/handler.test.ts +++ b/packages/claude-messages/src/__tests__/handler.test.ts @@ -1317,6 +1317,25 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(mockMessagesCreate.mock.calls[0][0].max_tokens).toBe(2048); }); + // This handler wraps the raw @anthropic-ai/sdk client, which reads snake_case keys itself, so + // (unlike the four framework handlers) the UI's snake_case must reach messages.create untouched + // rather than being camelized. + it('forwards snake_case model.parameters keys unchanged, not camelized', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { max_tokens: 300, top_p: 0.8, stop_sequences: ['STOP'] } }, + }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call.max_tokens).toBe(300); + expect(call.top_p).toBe(0.8); + expect(call.stop_sequences).toEqual(['STOP']); + expect(call.maxTokens).toBeUndefined(); + expect(call.topP).toBeUndefined(); + }); + it('does not let model.parameters override model, messages, tools, or system', async () => { mockMessagesCreate.mockResolvedValue(mockFinalResponse()); const cfg = { diff --git a/packages/client/src/__tests__/utils.test.ts b/packages/client/src/__tests__/utils.test.ts index 8bdd3d92..65ccc6c1 100644 --- a/packages/client/src/__tests__/utils.test.ts +++ b/packages/client/src/__tests__/utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { + camelizeModelParameters, createHandler, normalizeMode, normalizeModelParameters, @@ -647,3 +648,76 @@ describe('normalizeModelParameters', () => { expect(normalizeModelParameters([])).toEqual([]); }); }); + +// ─── camelizeModelParameters ─────────────────────────────────────────────────── + +describe('camelizeModelParameters', () => { + it('converts snake_case keys generated from the key itself', () => { + expect( + camelizeModelParameters({ + max_turns: 3, + top_p: 0.9, + max_output_tokens: 512, + frequency_penalty: 0.2, + }), + ).toEqual({ + maxTurns: 3, + topP: 0.9, + maxOutputTokens: 512, + frequencyPenalty: 0.2, + }); + }); + + it('leaves keys with no underscore unchanged', () => { + expect(camelizeModelParameters({ temperature: 0.7, store: true, maxTurns: 3 })).toEqual({ + temperature: 0.7, + store: true, + maxTurns: 3, + }); + }); + + it('converts only top-level keys, leaving nested values untouched', () => { + const input = { max_turns: 3, thinking: { type: 'enabled', budget_tokens: 1024 } }; + expect(camelizeModelParameters(input)).toEqual({ + maxTurns: 3, + thinking: { type: 'enabled', budget_tokens: 1024 }, + }); + }); + + it('keeps the nested object reference unchanged (byte for byte)', () => { + const thinking = { type: 'enabled', budget_tokens: 1024 }; + const result = camelizeModelParameters({ thinking }); + expect(result.thinking).toBe(thinking); + }); + + it('prefers the snake_case spelling when both spellings are set, snake_case first', () => { + expect(camelizeModelParameters({ max_turns: 5, maxTurns: 1 })).toEqual({ maxTurns: 5 }); + }); + + it('prefers the snake_case spelling when both spellings are set, camelCase first', () => { + expect(camelizeModelParameters({ maxTurns: 1, max_turns: 5 })).toEqual({ maxTurns: 5 }); + }); + + it('leaves a key with a leading underscore unchanged', () => { + expect(camelizeModelParameters({ _foo: 1 })).toEqual({ _foo: 1 }); + }); + + it('leaves a key with a trailing underscore unchanged', () => { + expect(camelizeModelParameters({ foo_: 1 })).toEqual({ foo_: 1 }); + }); + + it('leaves a key with a doubled underscore unchanged', () => { + expect(camelizeModelParameters({ foo__bar: 1 })).toEqual({ foo__bar: 1 }); + }); + + it('returns a new object and never mutates the input', () => { + const input = { max_turns: 3 }; + const result = camelizeModelParameters(input); + expect(result).not.toBe(input); + expect(input).toEqual({ max_turns: 3 }); + }); + + it('returns {} for an empty bag', () => { + expect(camelizeModelParameters({})).toEqual({}); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ac3d978d..95f678f0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -84,6 +84,7 @@ export { GraphTopologySchema, NATIVE_TOOL_KEY, NativeTool } from './types.js'; export type { RunUsage, SpanUsage } from './utils.js'; export { addCachedTokensToInput, + camelizeModelParameters, collapseMessagesToInstructions, createHandler, createRunUsage, diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 79bea9b6..0379a30f 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -578,6 +578,61 @@ export function normalizeModelParameters(parameters: unknown): Record) : {}; } +/** + * Converts one snake_case key to camelCase, or returns `undefined` when the key should pass + * through unchanged: a key with no underscore (already camelCase, or a single word like + * `temperature`), or one with a leading, trailing, or doubled underscore, where guessing the + * intended split would be wrong more often than leaving it alone. + */ +function camelizeKey(key: string): string | undefined { + if (!key.includes('_')) return undefined; + const parts = key.split('_'); + if (parts.some((part) => part.length === 0)) return undefined; + return ( + parts[0] + + parts + .slice(1) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join('') + ); +} + +/** + * Converts the TOP-LEVEL keys of a `model.parameters` bag from snake_case (the LaunchDarkly UI's + * convention, and the wire name every provider SDK expects) to camelCase, for the four handlers + * whose underlying framework — as opposed to a raw provider client — only reads camelCase option + * names: `max_turns` becomes `maxTurns`, `top_p` becomes `topP`. Every camelCase key generated + * this way, so no lookup table to keep in sync with new provider parameters. + * + * Only top-level keys convert. A nested value such as `thinking: { budget_tokens: 1024 }` is + * copied through untouched, because these frameworks hand that inner object to the provider API + * raw, snake_case and all. + * + * When a bag sets both spellings of one key (`max_turns` and `maxTurns`), the snake_case one + * wins — it is the convention the UI writes, so it is treated as authoritative — deterministically + * regardless of which key came first in the object. + * + * Returns a new object; never mutates the input. + */ +export function camelizeModelParameters(parameters: Record): Record { + const result: Record = {}; + const converted: Array<[string, unknown]> = []; + for (const [key, value] of Object.entries(parameters)) { + const camelKey = camelizeKey(key); + if (camelKey === undefined) { + result[key] = value; + } else { + converted.push([camelKey, value]); + } + } + // Applied after every unconverted key, so a snake_case key always overwrites a colliding + // camelCase key regardless of iteration order. + for (const [camelKey, value] of converted) { + result[camelKey] = value; + } + return result; +} + /** * Returns a copy of `trackData` without `modelKey` / `modelVersion`. Used when * overlaying a judge's `trackData` on its parent's so a judge without a pinned diff --git a/packages/langchain-agents/src/__tests__/handler.test.ts b/packages/langchain-agents/src/__tests__/handler.test.ts index c499bd7b..54bfd0ca 100644 --- a/packages/langchain-agents/src/__tests__/handler.test.ts +++ b/packages/langchain-agents/src/__tests__/handler.test.ts @@ -1003,7 +1003,11 @@ describe('model source', () => { expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: llm })); }); - it('spreads model.parameters into the default OpenAI constructor', async () => { + // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so + // `modelConstructorArgs` camelizes the snake_case bag the UI writes before spreading it in + // here — this is the exact conversion the casing fix adds, so this assertion changed from + // `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. + it('spreads model.parameters into the default OpenAI constructor, camelized', async () => { const constructed = { tag: 'default-openai' }; MockChatOpenAI.mockImplementation(function MockChatOpenAI() { return constructed; @@ -1015,13 +1019,27 @@ describe('model source', () => { await createLangChainAgentsHandler()(cfg as any, 'q'); expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, - max_tokens: 512, + maxTokens: 512, tools: ['openai-tool'], model: 'gpt-4o', }); expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: constructed })); }); + it('camelCase model.parameters keys still pass through unchanged into the default constructor', async () => { + const constructed = { tag: 'default-openai-camel' }; + MockChatOpenAI.mockImplementation(function MockChatOpenAI() { + return constructed; + }); + const cfg = { + model: { name: 'gpt-4o', parameters: { temperature: 0.2, maxTokens: 512 } }, + provider: { name: 'LangChain' }, + instructions: 'Be helpful.', + }; + await createLangChainAgentsHandler()(cfg as any, 'q'); + expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); + }); + it('spreads model.parameters into the default Anthropic constructor', async () => { const constructed = { tag: 'default-anthropic' }; MockChatAnthropic.mockImplementation(function MockChatAnthropic() { diff --git a/packages/langchain-agents/src/__tests__/native-graph.test.ts b/packages/langchain-agents/src/__tests__/native-graph.test.ts index 47fd1f77..1f7d1c14 100644 --- a/packages/langchain-agents/src/__tests__/native-graph.test.ts +++ b/packages/langchain-agents/src/__tests__/native-graph.test.ts @@ -178,13 +178,25 @@ describe('toLangGraph', () => { expect(registeredNames).toContain('leaf'); }); - it('spreads model.parameters into the default ChatOpenAI constructor', async () => { + // ChatOpenAI only reads camelCase, so the graph's default model factory camelizes the + // snake_case bag the UI writes — the exact conversion the casing fix adds, so this assertion + // changed from `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. + it('spreads model.parameters into the default ChatOpenAI constructor, camelized', async () => { const root = makeNode('root', '', []); root.config.model.parameters = { temperature: 0.2, max_tokens: 512 }; const def = makeGraphDef([root], {}, 'root'); MockChatOpenAI.mockClear(); await toLangGraph(Promise.resolve(def)).invoke('hi'); - expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, max_tokens: 512, model: 'gpt-4o' }); + expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); + }); + + it('leaves camelCase model.parameters keys unchanged for the default ChatOpenAI constructor', async () => { + const root = makeNode('root', '', []); + root.config.model.parameters = { temperature: 0.2, maxTokens: 512 }; + const def = makeGraphDef([root], {}, 'root'); + MockChatOpenAI.mockClear(); + await toLangGraph(Promise.resolve(def)).invoke('hi'); + expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); }); it('wires the root node from START', async () => { diff --git a/packages/langchain-agents/src/__tests__/wire.test.ts b/packages/langchain-agents/src/__tests__/wire.test.ts new file mode 100644 index 00000000..95868c12 --- /dev/null +++ b/packages/langchain-agents/src/__tests__/wire.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createLangChainAgentsHandler } from '../handler.js'; + +// This file deliberately does NOT mock @langchain/openai or the real `langchain` createAgent. +// Every other test in this package mocks both out, so a handler-internal bug that maps a config +// key to the wrong ChatOpenAI constructor field would never reach a real request body — which is +// exactly how the snake_case bug this package's casing fix addresses went unnoticed. Constructing +// a real ChatOpenAI (via the real agent executor) and intercepting its outgoing fetch call is the +// only check that would have caught it. + +const baseConfig = { + model: { + name: 'gpt-4o', + parameters: { + top_p: 0.42, + max_tokens: 321, + }, + }, + provider: { name: 'LangChain' }, + instructions: 'You are helpful.', +}; + +describe('createLangChainAgentsHandler — wire request (real ChatOpenAI + createAgent, unmocked)', () => { + it('sends snake_case model.parameters onto the wire as top_p / max_tokens', async () => { + let capturedBody: Record | undefined; + + const fetchFn = async (_url: unknown, init?: { body?: unknown }) => { + capturedBody = JSON.parse(String(init?.body)); + // Abort before any real network call is attempted. + throw new Error('aborted: request captured'); + }; + + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + ...baseConfig.model.parameters, + // LangChain's own AsyncCaller retries a thrown call up to 6 times by default; without + // this the thrown fetch above is retried repeatedly and the test times out. + maxRetries: 0, + configuration: { apiKey: 'test-key', fetch: fetchFn }, + }, + }, + }; + + await expect(createLangChainAgentsHandler()(config as any, 'hi')).rejects.toThrow(); + + expect(capturedBody).toBeDefined(); + expect(capturedBody!.top_p).toBe(0.42); + expect(capturedBody!.max_tokens).toBe(321); + }, 10000); +}); diff --git a/packages/langchain-agents/src/handler.ts b/packages/langchain-agents/src/handler.ts index 52b5ae51..18481d97 100644 --- a/packages/langchain-agents/src/handler.ts +++ b/packages/langchain-agents/src/handler.ts @@ -6,6 +6,7 @@ import { type AiConfigRep, type ConfigTurn, type ContentCaptureOptions, + camelizeModelParameters, composeHistory, config, createHandler, @@ -261,7 +262,7 @@ export function buildSpanCallbacks( export type LangChainModelSource = BaseChatModel | ((config: AiConfigRep) => BaseChatModel | Promise); function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { ...normalizeModelParameters(config.model?.parameters) }; + const parameters = { ...camelizeModelParameters(normalizeModelParameters(config.model?.parameters)) }; if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; return { ...parameters, model: resolvedModelName(config, fallbackName) }; } diff --git a/packages/langchain-agents/src/native-graph.ts b/packages/langchain-agents/src/native-graph.ts index d55b11ce..3f3e2052 100644 --- a/packages/langchain-agents/src/native-graph.ts +++ b/packages/langchain-agents/src/native-graph.ts @@ -6,6 +6,7 @@ import { ToolNode, toolsCondition } from '@langchain/langgraph/prebuilt'; import { ChatOpenAI } from '@langchain/openai'; import type { LDContext } from '@launchdarkly/ai-server'; import { + camelizeModelParameters, composeHistory, type GraphDefinition, type GraphNode, @@ -145,7 +146,7 @@ export const toLangGraph = ( opts?.modelFactory ?? ((node) => new ChatOpenAI({ - ...normalizeModelParameters(node.config.model.parameters), + ...camelizeModelParameters(normalizeModelParameters(node.config.model.parameters)), model: node.config.model.name, })); const ldContext = opts?.context; diff --git a/packages/langchain-messages/src/__tests__/handler.test.ts b/packages/langchain-messages/src/__tests__/handler.test.ts index 15853414..0e9566a9 100644 --- a/packages/langchain-messages/src/__tests__/handler.test.ts +++ b/packages/langchain-messages/src/__tests__/handler.test.ts @@ -1279,7 +1279,11 @@ describe('model source', () => { expect(result.output).toBe('from-instance'); }); - it('spreads model.parameters into the default OpenAI constructor', async () => { + // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so + // `modelConstructorArgs` camelizes the snake_case bag the UI writes before spreading it in + // here — this is the exact conversion the casing fix adds, so this assertion changed from + // `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. + it('spreads model.parameters into the default OpenAI constructor, camelized', async () => { vi.mocked(ChatOpenAI).mockImplementation(function MockChatOpenAI() { return makeMockLLM('default-openai') as any; }); @@ -1290,12 +1294,24 @@ describe('model source', () => { await createLangChainHandler()(cfg as any, 'q'); expect(ChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, - max_tokens: 512, + maxTokens: 512, tools: ['openai-tool'], model: 'gpt-4o', }); }); + it('camelCase model.parameters keys still pass through unchanged into the default constructor', async () => { + vi.mocked(ChatOpenAI).mockImplementation(function MockChatOpenAI() { + return makeMockLLM('default-openai') as any; + }); + const cfg = { + ...baseConfig, + model: { name: 'gpt-4o', parameters: { temperature: 0.2, maxTokens: 512 } }, + }; + await createLangChainHandler()(cfg as any, 'q'); + expect(ChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); + }); + it('spreads model.parameters into the default Anthropic constructor', async () => { vi.mocked(ChatAnthropic).mockImplementation(function MockChatAnthropic() { return makeMockLLM('default-anthropic') as any; diff --git a/packages/langchain-messages/src/__tests__/wire.test.ts b/packages/langchain-messages/src/__tests__/wire.test.ts new file mode 100644 index 00000000..6bf827d6 --- /dev/null +++ b/packages/langchain-messages/src/__tests__/wire.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createLangChainHandler } from '../handler.js'; + +// This file deliberately does NOT mock @langchain/openai. Every other test in this package mocks +// it out, so a handler-internal bug that maps a config key to the wrong ChatOpenAI constructor +// field would never reach a real request body — which is exactly how the snake_case bug this +// package's casing fix addresses went unnoticed. Constructing a real ChatOpenAI and intercepting +// its outgoing fetch call is the only check that would have caught it. + +const baseConfig = { + model: { + name: 'gpt-4o', + parameters: { + top_p: 0.42, + max_tokens: 321, + }, + }, + provider: { name: 'LangChain' }, + instructions: 'You are helpful.', +}; + +describe('createLangChainHandler — wire request (real ChatOpenAI, unmocked)', () => { + it('sends snake_case model.parameters onto the wire as top_p / max_tokens', async () => { + let capturedBody: Record | undefined; + + const fetchFn = async (_url: unknown, init?: { body?: unknown }) => { + capturedBody = JSON.parse(String(init?.body)); + // Abort before any real network call is attempted. + throw new Error('aborted: request captured'); + }; + + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + ...baseConfig.model.parameters, + // `maxRetries: 0` is LangChain's own AsyncCaller retry count (defaults to 6, with + // backoff), separate from the openai client's own maxRetries inside `configuration`. + // Without it a thrown fetch is retried repeatedly and the test times out. + maxRetries: 0, + configuration: { apiKey: 'test-key', fetch: fetchFn }, + }, + }, + }; + + await expect(createLangChainHandler()(config as any, 'hi')).rejects.toThrow(); + + expect(capturedBody).toBeDefined(); + expect(capturedBody!.top_p).toBe(0.42); + expect(capturedBody!.max_tokens).toBe(321); + }); +}); diff --git a/packages/langchain-messages/src/handler.ts b/packages/langchain-messages/src/handler.ts index 8480f40c..48e9c6dd 100644 --- a/packages/langchain-messages/src/handler.ts +++ b/packages/langchain-messages/src/handler.ts @@ -4,6 +4,7 @@ import { type AiConfigRep, type CanonicalTurn, type ContentCaptureOptions, + camelizeModelParameters, composeHistory, config, createHandler, @@ -171,7 +172,7 @@ function normalizeOutputSchema(schema: Record): Record BaseChatModel | Promise); function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { ...normalizeModelParameters(config.model?.parameters) }; + const parameters = { ...camelizeModelParameters(normalizeModelParameters(config.model?.parameters)) }; if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; // Name from the config always wins over a colliding `model` key in the parameter bag. return { ...parameters, model: resolvedModelName(config, fallbackName) }; diff --git a/packages/openai-agents/src/__tests__/handler.test.ts b/packages/openai-agents/src/__tests__/handler.test.ts index 83c23380..7f5fe5e5 100644 --- a/packages/openai-agents/src/__tests__/handler.test.ts +++ b/packages/openai-agents/src/__tests__/handler.test.ts @@ -279,6 +279,22 @@ describe('createOpenAIAgentHandler', () => { expect(runOptions).toEqual({ maxTurns: 3 }); }); + // The LaunchDarkly UI writes model.parameters in snake_case (the wire name every provider + // client expects), but the Agents SDK's ModelSettings/Runner.run only read camelCase — so a + // config saved as `max_turns` / `top_p` must reach the Agents SDK as `maxTurns` / `topP`. + it('converts snake_case model.parameters keys to camelCase for modelSettings and Runner.run', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { max_turns: 4, top_p: 0.9, max_tokens: 256 } }, + }; + await createOpenAIAgentHandler()(config as any, 'q'); + const agentArgs = mockAgentConstructor.mock.calls[0][0]; + expect(agentArgs.modelSettings).toEqual({ maxTurns: 4, topP: 0.9, maxTokens: 256 }); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions).toEqual({ maxTurns: 4 }); + }); + // ── 1.3 Tool conversion ───────────────────────────────────────────────────── it('creates agent tools when config.tools is present', async () => { @@ -636,6 +652,21 @@ describe('createOpenAIAgentHandler', () => { expect(runOptions).toMatchObject({ maxTurns: 5 }); }); + it('converts a snake_case max_turns to maxTurns on the streaming path', async () => { + const streamedResult = { + [Symbol.asyncIterator]: async function* () {}, + state: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + finalOutput: '', + }; + mockRun.mockResolvedValue(streamedResult); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_turns: 6, top_p: 0.8 } } }; + const handler = createOpenAIAgentHandler(); + await collectStream(handler.stream?.(config as any, 'q', {}, {})); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ maxTurns: 6, topP: 0.8 }); + const runOptions = mockRun.mock.calls[0][2]; + expect(runOptions).toMatchObject({ maxTurns: 6 }); + }); + it('does not set modelSettings or maxTurns on the streaming path when model.parameters is absent', async () => { const streamedResult = { [Symbol.asyncIterator]: async function* () {}, diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index 5c5021c9..2652d39d 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -3,6 +3,7 @@ import { type CanonicalTurn, type ConfigTurn, type ContentCaptureOptions, + camelizeModelParameters, composeHistory, config, contentToText, @@ -503,17 +504,18 @@ function configConversationTurns(config: AiConfigRep, variables: Record 0 ? (settings as ModelSettings) : undefined; } /** * `maxTurns` is a `Runner.run` option, not a `ModelSettings` field — it caps the agentic loop * rather than tuning any single model call — so it is read out of `model.parameters` separately - * and forwarded to `run()` instead of the `Agent` constructor. + * and forwarded to `run()` instead of the `Agent` constructor. The bag is camelized first so a + * config that saves `max_turns` (the UI's convention) is read here too, not just `maxTurns`. */ function buildMaxTurns(parameters: AiConfigRep['model']['parameters']): number | undefined { - const maxTurns = parameters?.maxTurns; + const maxTurns = camelizeModelParameters(normalizeModelParameters(parameters)).maxTurns; return typeof maxTurns === 'number' ? maxTurns : undefined; } diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index 3fec3cfb..c259bfa6 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -333,6 +333,23 @@ describe('createOpenAIHandler', () => { expect(call.foo).toBe('bar'); }); + // This handler wraps the raw Responses API client, which reads snake_case keys itself, so + // (unlike the four framework handlers) the UI's snake_case must reach responses.create untouched + // rather than being camelized. + it('forwards snake_case model.parameters keys unchanged, not camelized', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { top_p: 0.7, max_output_tokens: 128 } }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.top_p).toBe(0.7); + expect(call.max_output_tokens).toBe(128); + expect(call.topP).toBeUndefined(); + expect(call.maxOutputTokens).toBeUndefined(); + }); + it('does not let model.parameters override model, input, tools, or previous_response_id', async () => { mockResponsesCreate.mockResolvedValue(mockFinalResponse()); const config = { From cad26b4dd124206970f3a9d25501a61efe51097d Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:18:21 -0400 Subject: [PATCH 09/23] docs(handlers): say the agent handlers camelize model.parameters, not forward it verbatim Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/claude-agents/src/handler.ts | 3 ++- packages/openai-agents/src/handler.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index 34421cd8..bdc03373 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -895,7 +895,8 @@ function buildQueryOptions( // biome-ignore lint/suspicious/noExplicitAny: Claude SDK hooks config type is not publicly exported hooks: any, /** - * The customer's `model.parameters`, forwarded verbatim. Untrusted: it is whatever was + * The customer's `model.parameters`, with top-level keys camelized for the Claude Agent + * SDK (`max_turns` becomes `maxTurns`). Untrusted: it is whatever was * saved on the AI Config, so it is spread FIRST and every handler-owned key below * overrides it. Passed explicitly rather than folded into `internalOptions` so the two * trust levels cannot be confused at a call site. diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index 2652d39d..3c5a69db 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -499,7 +499,8 @@ function configConversationTurns(config: AiConfigRep, variables: Record Date: Thu, 24 Sep 2026 11:55:35 -0400 Subject: [PATCH 10/23] feat(client): restore pickForwardedModelParameters for closed provider request types The four framework handlers correctly forward the whole model.parameters bag via normalizeModelParameters, since their frameworks ignore keys they do not recognize. The two handlers that wrap a raw provider client (claude-messages, openai-messages) do not have that safety net: an unsupported key reaches the wire and the provider returns 400. Restores pickForwardedModelParameters (previously dropped in cf9a611) as a shared, exported helper: pick the subset of parameters whose keys appear in an explicit allowlist, drop the rest silently. The two raw-client handlers use it next to a compiler-checked allowlist derived from each SDK's own request type, so the list cannot silently drift. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/client/src/__tests__/utils.test.ts | 26 ++++++++++++ packages/client/src/index.ts | 1 + packages/client/src/utils.ts | 46 +++++++++++++++++---- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/client/src/__tests__/utils.test.ts b/packages/client/src/__tests__/utils.test.ts index 65ccc6c1..704a4f0f 100644 --- a/packages/client/src/__tests__/utils.test.ts +++ b/packages/client/src/__tests__/utils.test.ts @@ -8,6 +8,7 @@ import { parseJSONWithPossibleFences, parseTemplate, parseUsage, + pickForwardedModelParameters, setLdSpanAttributes, setUsageSpanAttributes, } from '../utils.js'; @@ -649,6 +650,31 @@ describe('normalizeModelParameters', () => { }); }); +// ─── pickForwardedModelParameters ───────────────────────────────────────────── + +describe('pickForwardedModelParameters', () => { + it('returns {} when parameters is undefined', () => { + expect(pickForwardedModelParameters(undefined, ['temperature'])).toEqual({}); + }); + + it('returns {} when parameters sets none of the allowed keys', () => { + expect(pickForwardedModelParameters({ made_up_key: 'nope' }, ['temperature'])).toEqual({}); + }); + + it('picks only the keys present in both parameters and the allowlist', () => { + const parameters = { temperature: 0.5, top_p: 0.9, made_up_key: 'nope' }; + expect(pickForwardedModelParameters(parameters, ['temperature', 'top_p'])).toEqual({ + temperature: 0.5, + top_p: 0.9, + }); + }); + + it('omits an allowed key whose value is undefined', () => { + const parameters = { temperature: undefined, top_p: 0.9 }; + expect(pickForwardedModelParameters(parameters, ['temperature', 'top_p'])).toEqual({ top_p: 0.9 }); + }); +}); + // ─── camelizeModelParameters ─────────────────────────────────────────────────── describe('camelizeModelParameters', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 95f678f0..c06bedbf 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -94,6 +94,7 @@ export { omitModelStamps, parseJSONWithPossibleFences, parseTemplate, + pickForwardedModelParameters, setLdSpanAttributes, setModelIdentityAttributes, setUsageSpanAttributes, diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 0379a30f..ecdfb344 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -566,18 +566,50 @@ export function parseJSONWithPossibleFences(rawText: string): T | null { /** * Returns `parameters` unchanged when it is a usable object, `{}` otherwise. * - * Every handler spreads the *entire* `model.parameters` bag straight into its provider call - * rather than picking an allowlist. The LaunchDarkly UI already constrains which keys a customer - * can save, and an unsupported key reaching the provider is expected to surface as a provider - * error rather than being silently dropped here. This is the defensive guard the call sites used - * to repeat inline: `model.parameters` is optional on `AiConfigRep`, and a config's own `model` - * field is technically reachable as `unknown` at these call sites, so both must be checked before - * spreading. + * The four framework handlers (`claude-agents`, `openai-agents`, `langchain-messages`, + * `langchain-agents`) spread the *entire* `model.parameters` bag straight into their underlying + * framework call rather than picking an allowlist — their frameworks already ignore keys they do + * not recognize, so narrowing here would only drop settings that work today for no safety benefit. + * This is the defensive guard those call sites used to repeat inline: `model.parameters` is + * optional on `AiConfigRep`, and a config's own `model` field is technically reachable as + * `unknown` at these call sites, so both must be checked before spreading. + * + * The two handlers that wrap a raw provider client (`claude-messages`, `openai-messages`) do NOT + * use this: an unrecognized key reaches those providers' wire APIs verbatim and is rejected with a + * 400, so they use `pickForwardedModelParameters` against an explicit, compiler-checked allowlist + * instead. */ export function normalizeModelParameters(parameters: unknown): Record { return parameters && typeof parameters === 'object' ? (parameters as Record) : {}; } +/** + * Picks the subset of `parameters` whose keys appear in `keys`, unchanged otherwise: a config that + * sets nothing in `keys` produces `{}`, so the provider call sees exactly what it always has. + * + * For handlers whose provider request type is **closed** — an unknown field is a wire-level 400 + * from the provider, not a harmless extra — rather than open-ended. Those handlers + * (`claude-messages`, `openai-messages`) each keep their own `FORWARDED_*_KEYS` allowlist next to + * the request shape it maps onto, checked at compile time against the SDK's own base request type + * so the list cannot silently drift; this helper only owns the picking loop. + * + * The four framework handlers are the opposite case — their frameworks take an open-ended options + * bag and already ignore keys they do not recognize, so narrowing it to a key list would silently + * drop settings that work today. They use `normalizeModelParameters` instead, which forwards the + * whole object. + */ +export function pickForwardedModelParameters( + parameters: Record | undefined, + keys: ReadonlyArray, +): Record { + const picked: Record = {}; + if (!parameters) return picked; + for (const key of keys) { + if (parameters[key] !== undefined) picked[key] = parameters[key]; + } + return picked; +} + /** * Converts one snake_case key to camelCase, or returns `undefined` when the key should pass * through unchanged: a key with no underscore (already camelCase, or a single word like From 5c51dc978a313c35578cb138aa61f7b59fd3a465 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:55:49 -0400 Subject: [PATCH 11/23] feat(openai-messages): forward only Responses API keys from model.parameters This handler wraps the raw OpenAI SDK's responses.create / responses.stream, which reject an unrecognized field with a 400. The LaunchDarkly UI offers the Chat Completions parameter set, so a config that sets max_tokens, frequency_penalty, presence_penalty, seed, n, stop, response_format, logit_bias, logprobs, max_completion_tokens, audio, modalities, or prediction used to reach the API verbatim and would now break the run. FORWARDED_MODEL_PARAMETER_KEYS lists every ResponseCreateParamsBase key this handler forwards. A compile-time exhaustiveness check classifies every other key on that type as handler-owned (input, model, previous_response_id, text, tools) or excluded as transport/plumbing, with a reason in the comment above the list; a key the SDK adds and this file does not classify fails to build, naming the key. buildModelParameterOptions also renames the two Chat Completions token-limit spellings the UI offers, max_tokens and max_completion_tokens, to the Responses API's max_output_tokens, before filtering. Precedence when a config sets more than one spelling: an explicit max_output_tokens wins, then max_completion_tokens, then max_tokens. Applied at every responses.create / responses.stream call site: the initial blocking turn, the blocking tool-loop turn, and both branches of the streaming turn. Behaviour is unchanged when model.parameters is absent or empty. Updates the handler.test.ts case that asserted an unrecognized key reached the request to assert the opposite, and adds coverage for the accepted-key path, the dropped-key path, both renames, and precedence. Adds wire.test.ts, which does not mock the openai package and instead stubs global fetch to assert on the literal JSON body responses.create sends. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/handler.test.ts | 55 +++++++++-- .../src/__tests__/wire.test.ts | 86 ++++++++++++++++ packages/openai-messages/src/handler.ts | 97 ++++++++++++++++++- 3 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 packages/openai-messages/src/__tests__/wire.test.ts diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index c259bfa6..f3fe11fa 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -319,18 +319,61 @@ describe('createOpenAIHandler', () => { expect(call.max_output_tokens).toBe(256); }); - it('forwards unrecognized model.parameters keys through to the provider call rather than dropping them', async () => { - // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key - // reaching the Responses API is expected to surface as a provider error, not be silently dropped. + it('drops an unrecognized model.parameters key rather than forwarding it', async () => { + // Unlike the four framework handlers, this handler wraps the raw Responses API client: an + // unsupported key reaching responses.create is a wire-level 400, not a harmlessly ignored extra. mockResponsesCreate.mockResolvedValue(mockFinalResponse()); const config = { ...baseConfig, - model: { ...baseConfig.model, parameters: { max_tokens: 999, foo: 'bar' } }, + model: { ...baseConfig.model, parameters: { foo: 'bar' } }, }; await createOpenAIHandler()(config as any, 'q'); const call = mockResponsesCreate.mock.calls[0][0]; - expect(call.max_tokens).toBe(999); - expect(call.foo).toBe('bar'); + expect(call).not.toHaveProperty('foo'); + }); + + it('renames max_tokens (Chat Completions spelling) to max_output_tokens (Responses spelling)', async () => { + // The LaunchDarkly UI offers the Chat Completions parameter set; the Responses API this + // handler calls only accepts max_output_tokens. Without the rename this would drop silently + // (max_tokens is not a Responses key) or, worse, 400 if the UI ever changes to forward it raw. + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_tokens: 999 } } }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.max_output_tokens).toBe(999); + expect(call).not.toHaveProperty('max_tokens'); + }); + + it('renames max_completion_tokens to max_output_tokens', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_completion_tokens: 777 } } }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.max_output_tokens).toBe(777); + expect(call).not.toHaveProperty('max_completion_tokens'); + }); + + it('precedence: an explicit max_output_tokens wins over max_completion_tokens and max_tokens', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { max_tokens: 1, max_completion_tokens: 2, max_output_tokens: 3 }, + }, + }; + await createOpenAIHandler()(config as any, 'q'); + expect(mockResponsesCreate.mock.calls[0][0].max_output_tokens).toBe(3); + }); + + it('precedence: max_completion_tokens wins over max_tokens when max_output_tokens is absent', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { max_tokens: 1, max_completion_tokens: 2 } }, + }; + await createOpenAIHandler()(config as any, 'q'); + expect(mockResponsesCreate.mock.calls[0][0].max_output_tokens).toBe(2); }); // This handler wraps the raw Responses API client, which reads snake_case keys itself, so diff --git a/packages/openai-messages/src/__tests__/wire.test.ts b/packages/openai-messages/src/__tests__/wire.test.ts new file mode 100644 index 00000000..775cb7cc --- /dev/null +++ b/packages/openai-messages/src/__tests__/wire.test.ts @@ -0,0 +1,86 @@ +// ─── Wire-level tests ─────────────────────────────────────────────────────── +// +// Unlike handler.test.ts, this file does NOT mock the `openai` package. It lets the real +// OpenAI SDK build a request and intercepts the outgoing HTTP call via a stubbed global +// `fetch`, so these tests prove the JSON body actually sent to the Responses API — not just +// what reaches a mocked `responses.create`. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockFetchResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function finalResponseBody() { + return { + id: 'resp_1', + object: 'response', + status: 'completed', + model: 'gpt-4o', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hi' }] }], + output_text: 'hi', + usage: { input_tokens: 1, output_tokens: 1 }, + }; +} + +describe('createOpenAIHandler — wire-level request body', () => { + let capturedBody: Record | undefined; + let fetchMock: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + process.env.OPENAI_API_KEY = 'test-key'; + capturedBody = undefined; + fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string); + return mockFetchResponse(finalResponseBody()); + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends max_output_tokens and drops frequency_penalty for a config that sets both Chat Completions keys', async () => { + const { createOpenAIHandler } = await import('../handler.js'); + const config = { + model: { + name: 'gpt-4o', + parameters: { + max_tokens: 256, + // Not a Responses API key; the LaunchDarkly UI offers it because it lists the Chat + // Completions parameter set. Must be dropped, not sent — the real API 400s on it. + frequency_penalty: 0.5, + }, + }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }; + + const handler = createOpenAIHandler(); + await handler(config as any, 'hi'); + + expect(fetchMock).toHaveBeenCalled(); + expect(capturedBody?.max_output_tokens).toBe(256); + expect(capturedBody).not.toHaveProperty('max_tokens'); + expect(capturedBody).not.toHaveProperty('frequency_penalty'); + }); + + it('forwards an accepted key (temperature) to the wire request', async () => { + const { createOpenAIHandler } = await import('../handler.js'); + const config = { + model: { name: 'gpt-4o', parameters: { temperature: 0.25 } }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }; + + const handler = createOpenAIHandler(); + await handler(config as any, 'hi'); + + expect(capturedBody?.temperature).toBe(0.25); + }); +}); diff --git a/packages/openai-messages/src/handler.ts b/packages/openai-messages/src/handler.ts index 65a3e715..98c2f89d 100644 --- a/packages/openai-messages/src/handler.ts +++ b/packages/openai-messages/src/handler.ts @@ -15,6 +15,7 @@ import { normalizeModelParameters, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, type SpanUsage, @@ -29,6 +30,7 @@ import { } from '@launchdarkly/ai-server'; import { type Context, context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'; import OpenAI from 'openai'; +import type { ResponseCreateParamsBase } from 'openai/resources/responses/responses'; const TRACER_NAME = '@launchdarkly/ai-openai-messages'; @@ -332,6 +334,93 @@ const jsonSchemaFormat = (schema: Record): any => ({ const toToolDefinitions = (tools: FunctionTool[]): ToolDefinitionInput[] => tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters })); +/** + * `ResponseCreateParamsBase` keys this handler forwards verbatim from `config.model.parameters`, + * after the `max_tokens` / `max_completion_tokens` → `max_output_tokens` rename below — the + * LaunchDarkly UI offers the Chat Completions parameter set, and the Responses API this handler + * calls does not accept either of those two names. + * + * Handler-owned (this handler sets these itself, from the config and the call shape, so a + * `model.parameters` value must not be able to override what it already decided): `model`, + * `input`, `tools`, `previous_response_id`, `text`. + * + * Excluded (not a model setting — transport/plumbing this handler does not use): + * - `background` — async execution mode, not a generation setting. + * - `context_management` — server-side conversation compaction, not a generation setting. + * - `conversation` — this handler threads multi-turn state itself via `previous_response_id` and + * the composed message history, not via OpenAI's server-side conversation objects. + * - `include` — controls which extra fields the API echoes back in the response body. + * - `prompt` — references one of OpenAI's own stored prompt templates, which conflicts with the + * `input` this handler already builds from `config.messages` / `config.instructions`. + * - `prompt_cache_key`, `prompt_cache_retention` — cache bucketing/retention hints; no effect on + * model output. + * - `safety_identifier`, `user` (`user` is `safety_identifier`'s deprecated predecessor) — + * end-user identifiers for OpenAI's own abuse monitoring, not a generation setting. + * - `store` — whether OpenAI persists the response server-side for later retrieval, not a + * generation setting. + * - `stream` — this handler selects streaming by choosing between `responses.create()` and + * `responses.stream()`, not by setting a field, so a config value here would fight the method + * actually invoked. + * - `stream_options` — only meaningful alongside `stream: true`; same reasoning as `stream`. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'instructions', + 'max_output_tokens', + 'metadata', + 'moderation', + 'parallel_tool_calls', + 'reasoning', + 'service_tier', + 'temperature', + 'tool_choice', + 'top_logprobs', + 'top_p', + 'truncation', +] as const; + +type OpenAIHandlerOwnedKeys = 'input' | 'model' | 'previous_response_id' | 'text' | 'tools'; +type OpenAIExcludedKeys = + | 'background' + | 'context_management' + | 'conversation' + | 'include' + | 'prompt' + | 'prompt_cache_key' + | 'prompt_cache_retention' + | 'safety_identifier' + | 'store' + | 'stream' + | 'stream_options' + | 'user'; +// If a key of ResponseCreateParamsBase is added to the SDK and not classified above as forwarded, +// handler-owned, or excluded, this type resolves to something other than `never` and the +// assignment below fails to compile, naming the unclassified key. +type OpenAIUndecidedModelParameterKeys = Exclude< + keyof ResponseCreateParamsBase, + OpenAIHandlerOwnedKeys | OpenAIExcludedKeys | (typeof FORWARDED_MODEL_PARAMETER_KEYS)[number] +>; +const _openaiModelParameterKeysExhaustive: Record = {} as Record< + never, + never +>; + +/** + * Picks the subset of `config.model.parameters` that maps onto `ResponseCreateParamsBase`, after + * renaming the two Chat Completions token-limit spellings the LaunchDarkly UI offers — + * `max_tokens` and `max_completion_tokens` — to the Responses API's own `max_output_tokens`. + * Precedence when a config sets more than one spelling: an explicit `max_output_tokens` wins, then + * `max_completion_tokens`, then `max_tokens`. A config that sets nothing here produces `{}`, so the + * provider call sees exactly what it always has. + */ +function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + const normalized = normalizeModelParameters(parameters); + const { max_tokens, max_completion_tokens, max_output_tokens, ...rest } = normalized; + const resolvedMaxOutputTokens = max_output_tokens ?? max_completion_tokens ?? max_tokens; + const withRenamedMaxTokens = + resolvedMaxOutputTokens !== undefined ? { ...rest, max_output_tokens: resolvedMaxOutputTokens } : rest; + return pickForwardedModelParameters(withRenamedMaxTokens, FORWARDED_MODEL_PARAMETER_KEYS); +} + export function createOpenAIHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { const openai = new OpenAI(); @@ -404,7 +493,7 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp let response = await runModelTurn( { - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, input: inputMessages, tools: tools.length > 0 ? tools : undefined, @@ -448,7 +537,7 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp response = await runModelTurn( { - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, previous_response_id: response.id, input: toolOutputs, @@ -533,14 +622,14 @@ export function createOpenAIHandler({ captureContent = false }: ContentCaptureOp try { const streamParams = previousResponseId ? { - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, previous_response_id: previousResponseId, input: currentInput, tools: undefined, } : { - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, input: currentInput, tools: tools.length > 0 ? tools : undefined, From 9a1819e030085078d3461238135b9e69d96b621e Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:56:02 -0400 Subject: [PATCH 12/23] feat(claude-messages): forward only Messages API keys from model.parameters This handler wraps the raw Anthropic SDK's messages.create / messages.stream, which reject an unrecognized field with a 400. The LaunchDarkly UI offers a top-level effort setting that the Messages API only accepts nested under output_config, so a config that set it used to reach the API verbatim and would now break the run. FORWARDED_MODEL_PARAMETER_KEYS lists every MessageCreateParamsBase key this handler forwards. A compile-time exhaustiveness check classifies every other key on that type as handler-owned (model, messages, system, tools, max_tokens) or excluded as transport/plumbing (stream, since this handler selects streaming by choosing between messages.create and messages.stream, not by setting a field), with the reason in the comment above the list; a key the SDK adds and this file does not classify fails to build, naming the key. buildModelParameterOptions also moves a top-level effort into output_config.effort before filtering. An output_config.effort already present in the config wins over the renamed value. The handler does not set output_config itself anywhere (outputFormat is folded into the system prompt instead), so there is no handler-owned output_config to protect here. Applied at both call sites inside the tool loop shared by the blocking and streaming paths. Keeps max_tokens ?? 1024. Behaviour is unchanged when model.parameters is absent or empty. Updates the two handler.test.ts cases that asserted an unrecognized key reached the request to assert the opposite, and adds coverage for the effort rename, the explicit-output_config.effort-wins case, and the merge with an output_config that also sets other fields. Adds wire.test.ts, which does not mock @anthropic-ai/sdk and instead stubs global fetch to assert on the literal JSON body messages.create sends. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/handler.test.ts | 46 +++++++++-- .../src/__tests__/wire.test.ts | 79 +++++++++++++++++++ packages/claude-messages/src/handler.ts | 65 ++++++++++++++- 3 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 packages/claude-messages/src/__tests__/wire.test.ts diff --git a/packages/claude-messages/src/__tests__/handler.test.ts b/packages/claude-messages/src/__tests__/handler.test.ts index 0a1fa21b..bd79d6c5 100644 --- a/packages/claude-messages/src/__tests__/handler.test.ts +++ b/packages/claude-messages/src/__tests__/handler.test.ts @@ -1361,14 +1361,48 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(call.tools).toBeUndefined(); }); - it('forwards an unrecognized model.parameters key rather than dropping it', async () => { - // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key - // reaching the Anthropic SDK is expected to surface as a provider error, not be silently dropped. + it('drops an unrecognized model.parameters key rather than forwarding it', async () => { + // Unlike the four framework handlers, this handler wraps the raw Anthropic SDK client: an + // unsupported key reaching messages.create is a wire-level 400, not a harmlessly ignored extra. mockMessagesCreate.mockResolvedValue(mockFinalResponse()); const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { made_up_key: 'nope' } } }; const handler = createClaudeMessagesHandler(); await handler(cfg as any, 'hi'); - expect(mockMessagesCreate.mock.calls[0][0]).toHaveProperty('made_up_key', 'nope'); + expect(mockMessagesCreate.mock.calls[0][0]).not.toHaveProperty('made_up_key'); + }); + + it('drops a UI-offered key the Messages API top level does not accept', async () => { + // The LaunchDarkly UI offers top-level `effort`; the Messages API only takes it nested under + // `output_config`. Without the rename in buildModelParameterOptions this would 400. + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: { effort: 'high' } } }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + const call = mockMessagesCreate.mock.calls[0][0]; + expect(call).not.toHaveProperty('effort'); + expect(call.output_config).toEqual({ effort: 'high' }); + }); + + it('an explicit output_config.effort in the config wins over the top-level effort rename', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { effort: 'high', output_config: { effort: 'low' } } }, + }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + expect(mockMessagesCreate.mock.calls[0][0].output_config).toEqual({ effort: 'low' }); + }); + + it('merges a renamed effort into an output_config that also sets other fields', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { effort: 'high', output_config: { format: { type: 'text' } } } }, + }; + const handler = createClaudeMessagesHandler(); + await handler(cfg as any, 'hi'); + expect(mockMessagesCreate.mock.calls[0][0].output_config).toEqual({ effort: 'high', format: { type: 'text' } }); }); it('leaves the call unchanged from today when model.parameters is absent', async () => { @@ -1418,7 +1452,7 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(call.top_k).toBe(10); }); - it('streaming path still defaults max_tokens to 1024 and forwards an unrecognized key', async () => { + it('streaming path still defaults max_tokens to 1024 and drops an unrecognized key', async () => { const finalMsg = { usage: { input_tokens: 3, output_tokens: 7 }, stop_reason: 'end_turn', @@ -1433,7 +1467,7 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { } const call = mockMessagesStream.mock.calls[0][0]; expect(call.max_tokens).toBe(1024); - expect(call).toHaveProperty('made_up_key', 'nope'); + expect(call).not.toHaveProperty('made_up_key'); }); it('streaming path does not let model.parameters override model/messages/system', async () => { diff --git a/packages/claude-messages/src/__tests__/wire.test.ts b/packages/claude-messages/src/__tests__/wire.test.ts new file mode 100644 index 00000000..04e73620 --- /dev/null +++ b/packages/claude-messages/src/__tests__/wire.test.ts @@ -0,0 +1,79 @@ +// ─── Wire-level tests ─────────────────────────────────────────────────────── +// +// Unlike handler.test.ts, this file does NOT mock `@anthropic-ai/sdk`. It lets the real +// Anthropic SDK build a request and intercepts the outgoing HTTP call via a stubbed global +// `fetch`, so these tests prove the JSON body actually sent to the Messages API — not just +// what reaches a mocked `messages.create`. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockFetchResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function finalMessageBody() { + return { + id: 'msg_1', + type: 'message', + role: 'assistant', + model: 'claude-3-5-sonnet-20241022', + content: [{ type: 'text', text: 'hi' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }; +} + +describe('createClaudeMessagesHandler — wire-level request body', () => { + let capturedBody: Record | undefined; + let fetchMock: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + process.env.ANTHROPIC_API_KEY = 'test-key'; + capturedBody = undefined; + fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string); + return mockFetchResponse(finalMessageBody()); + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('moves a UI-offered top-level effort into output_config.effort and drops a key the Messages API top level does not accept', async () => { + const { createClaudeMessagesHandler } = await import('../handler.js'); + const config = { + model: { name: 'claude-3-5-sonnet-20241022', parameters: { effort: 'high' } }, + provider: { name: 'Anthropic' }, + instructions: 'You are helpful.', + }; + + const handler = createClaudeMessagesHandler(); + await handler(config as any, 'hi'); + + expect(fetchMock).toHaveBeenCalled(); + expect(capturedBody).not.toHaveProperty('effort'); + expect(capturedBody?.output_config).toEqual({ effort: 'high' }); + }); + + it('forwards an accepted key (temperature) and drops an unrecognized one at the wire', async () => { + const { createClaudeMessagesHandler } = await import('../handler.js'); + const config = { + model: { name: 'claude-3-5-sonnet-20241022', parameters: { temperature: 0.3, made_up_key: 'nope' } }, + provider: { name: 'Anthropic' }, + instructions: 'You are helpful.', + }; + + const handler = createClaudeMessagesHandler(); + await handler(config as any, 'hi'); + + expect(capturedBody?.temperature).toBe(0.3); + expect(capturedBody).not.toHaveProperty('made_up_key'); + }); +}); diff --git a/packages/claude-messages/src/handler.ts b/packages/claude-messages/src/handler.ts index 64470661..ef4b950f 100644 --- a/packages/claude-messages/src/handler.ts +++ b/packages/claude-messages/src/handler.ts @@ -1,4 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; +import type { MessageCreateParamsBase } from '@anthropic-ai/sdk/resources/messages/messages'; import { type AiConfigRep, addCachedTokensToInput, @@ -16,6 +17,7 @@ import { normalizeModelParameters, type ProviderHandler, parseTemplate, + pickForwardedModelParameters, type SpanMessage, type SpanMessagePart, setInputContentAttributes, @@ -330,6 +332,65 @@ const buildMessages = ( return { messages, system }; }; +/** + * `MessageCreateParamsBase` keys this handler forwards verbatim from `config.model.parameters` + * (after the `effort` rename below is folded into `output_config`), beyond `max_tokens`, which is + * handled separately because it carries a default. + * + * Handler-owned (this handler sets these itself, from the config and the call shape, so a + * `model.parameters` value must not be able to override what it already decided): `model`, + * `messages`, `system`, `tools`, `max_tokens`. + * + * Excluded (not a model setting): `stream` — this handler selects streaming by choosing between + * `messages.create()` and `messages.stream()`, not by setting a field on the request body, so a + * config value here would fight the method actually invoked. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'cache_control', + 'container', + 'inference_geo', + 'metadata', + 'output_config', + 'service_tier', + 'stop_sequences', + 'temperature', + 'thinking', + 'tool_choice', + 'top_k', + 'top_p', +] as const; + +type AnthropicHandlerOwnedKeys = 'max_tokens' | 'messages' | 'model' | 'system' | 'tools'; +type AnthropicExcludedKeys = 'stream'; +// If a key of MessageCreateParamsBase is added to the SDK and not classified above as forwarded, +// handler-owned, or excluded, this type resolves to something other than `never` and the +// assignment below fails to compile, naming the unclassified key. +type AnthropicUndecidedModelParameterKeys = Exclude< + keyof MessageCreateParamsBase, + AnthropicHandlerOwnedKeys | AnthropicExcludedKeys | (typeof FORWARDED_MODEL_PARAMETER_KEYS)[number] +>; +const _anthropicModelParameterKeysExhaustive: Record = {} as Record< + never, + never +>; + +/** + * Picks the subset of `config.model.parameters` that maps onto `MessageCreateParamsBase`, after + * moving a top-level `effort` — the shape the LaunchDarkly UI writes — into `output_config.effort`, + * the shape the Messages API actually accepts. An `output_config.effort` already present in the + * config wins over the renamed value, since it is what the customer set explicitly for that field. + * A config that sets nothing here produces `{}`, so the provider call sees exactly what it always + * has. + */ +function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + const normalized = normalizeModelParameters(parameters); + const { effort, output_config, ...rest } = normalized; + const mergedOutputConfig = + effort !== undefined ? { effort, ...(output_config as Record | undefined) } : output_config; + const withEffortMoved = mergedOutputConfig !== undefined ? { ...rest, output_config: mergedOutputConfig } : rest; + return pickForwardedModelParameters(withEffortMoved, FORWARDED_MODEL_PARAMETER_KEYS); +} + const MAX_STEPS = 10; export function createClaudeMessagesHandler({ captureContent = false }: ContentCaptureOptions = {}): ProviderHandler { @@ -367,7 +428,7 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let response: Anthropic.Message; try { response = await anthropic.messages.create({ - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, max_tokens: maxTokens, system, @@ -528,7 +589,7 @@ export function createClaudeMessagesHandler({ captureContent = false }: ContentC let finalMsg: Anthropic.Message; try { const stream = anthropic.messages.stream({ - ...normalizeModelParameters(config.model.parameters), + ...buildModelParameterOptions(config.model.parameters), model: config.model.name, max_tokens: maxTokens, system, From 39e342d88ab24e7dccd247e2fd41dbc61a518ec8 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:59:58 -0400 Subject: [PATCH 13/23] fix(openai-messages): only exclude model.parameters keys that would break the handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator follow-up: TypeScript and Python must apply the same exclusion rule, and the previous excluded set went too far. Excluding stream_options, store, safety_identifier, prompt_cache_key, prompt_cache_retention, include, context_management, conversation, prompt, and user was based on "is this a generation setting", not "would this break the handler" — the latter is the actual contract: forward everything the API accepts unless setting it produces a broken or missing result. Moves store, user, safety_identifier, prompt_cache_key, prompt_cache_retention, include, and context_management from excluded to forwarded. The excluded set is now exactly five keys, matching the shared rule: - stream, stream_options: the handler chooses streaming itself by calling responses.create() vs responses.stream(). - background: the call returns before the output exists, so the handler gets no result. - conversation, prompt: supply server-side state/templates that conflict with the input the handler builds itself. The compile-time exhaustiveness check against ResponseCreateParamsBase still passes with the narrowed OpenAIExcludedKeys union (verified by removing 'user' from FORWARDED_MODEL_PARAMETER_KEYS and confirming tsc names it). claude-messages needed no change: its excluded set was already exactly { stream }, which is what the shared rule requires for the Messages API. Refreshed its doc comment to state the same "would break the handler" rule explicitly, no behavior change. Updates handler.test.ts to add coverage for the seven now-forwarded keys and to keep asserting the five still-excluded keys are dropped. Extends wire.test.ts with one case proving `store` reaches the literal wire request while `background` is dropped from it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/claude-messages/src/handler.ts | 11 +++- .../src/__tests__/handler.test.ts | 62 +++++++++++++++++++ .../src/__tests__/wire.test.ts | 15 +++++ packages/openai-messages/src/handler.ts | 55 +++++++--------- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/packages/claude-messages/src/handler.ts b/packages/claude-messages/src/handler.ts index ef4b950f..f703597c 100644 --- a/packages/claude-messages/src/handler.ts +++ b/packages/claude-messages/src/handler.ts @@ -337,13 +337,18 @@ const buildMessages = ( * (after the `effort` rename below is folded into `output_config`), beyond `max_tokens`, which is * handled separately because it carries a default. * + * The rule for this list: exclude a key only if setting it would BREAK the handler; forward + * everything else the API accepts, even settings with no obvious generation effect — those are + * forwarded because a config that sets one still gets a working call. + * * Handler-owned (this handler sets these itself, from the config and the call shape, so a * `model.parameters` value must not be able to override what it already decided): `model`, * `messages`, `system`, `tools`, `max_tokens`. * - * Excluded (not a model setting): `stream` — this handler selects streaming by choosing between - * `messages.create()` and `messages.stream()`, not by setting a field on the request body, so a - * config value here would fight the method actually invoked. + * Excluded (would break the handler): `stream` — this handler selects streaming by choosing + * between `messages.create()` and `messages.stream()`, not by setting a field on the request + * body, so a config value here would fight the method actually invoked rather than configure + * anything. */ const FORWARDED_MODEL_PARAMETER_KEYS = [ 'cache_control', diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index f3fe11fa..41fce7e6 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -332,6 +332,68 @@ describe('createOpenAIHandler', () => { expect(call).not.toHaveProperty('foo'); }); + // The rule is: exclude a key only if setting it would break the handler. These keys are real + // Responses API fields with no obvious generation effect, but a config that sets one still + // gets a working call, so they are forwarded rather than excluded. + it('forwards keys with no generation effect (store, user, safety_identifier, prompt_cache_key, prompt_cache_retention, include, context_management)', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + store: true, + user: 'user-123', + safety_identifier: 'user-123', + prompt_cache_key: 'cache-key', + prompt_cache_retention: '24h', + include: ['file_search_call.results'], + context_management: [{ type: 'compaction' }], + }, + }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call.store).toBe(true); + expect(call.user).toBe('user-123'); + expect(call.safety_identifier).toBe('user-123'); + expect(call.prompt_cache_key).toBe('cache-key'); + expect(call.prompt_cache_retention).toBe('24h'); + expect(call.include).toEqual(['file_search_call.results']); + expect(call.context_management).toEqual([{ type: 'compaction' }]); + }); + + it('drops background, conversation, and prompt, which would break the handler', async () => { + // background: the call would return before the output exists, so the handler gets no result. + // conversation, prompt: server-side state/templates that conflict with the input this handler + // builds itself. + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { background: true, conversation: 'conv_123', prompt: { id: 'pmpt_123' } }, + }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call).not.toHaveProperty('background'); + expect(call).not.toHaveProperty('conversation'); + expect(call).not.toHaveProperty('prompt'); + }); + + it('drops stream and stream_options, which the handler decides itself by calling create() vs stream()', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { stream: true, stream_options: { include_obfuscation: false } } }, + }; + await createOpenAIHandler()(config as any, 'q'); + const call = mockResponsesCreate.mock.calls[0][0]; + expect(call).not.toHaveProperty('stream'); + expect(call).not.toHaveProperty('stream_options'); + }); + it('renames max_tokens (Chat Completions spelling) to max_output_tokens (Responses spelling)', async () => { // The LaunchDarkly UI offers the Chat Completions parameter set; the Responses API this // handler calls only accepts max_output_tokens. Without the rename this would drop silently diff --git a/packages/openai-messages/src/__tests__/wire.test.ts b/packages/openai-messages/src/__tests__/wire.test.ts index 775cb7cc..b4e48397 100644 --- a/packages/openai-messages/src/__tests__/wire.test.ts +++ b/packages/openai-messages/src/__tests__/wire.test.ts @@ -83,4 +83,19 @@ describe('createOpenAIHandler — wire-level request body', () => { expect(capturedBody?.temperature).toBe(0.25); }); + + it('forwards store (no generation effect, but does not break the handler) and drops background (would break it)', async () => { + const { createOpenAIHandler } = await import('../handler.js'); + const config = { + model: { name: 'gpt-4o', parameters: { store: true, background: true } }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }; + + const handler = createOpenAIHandler(); + await handler(config as any, 'hi'); + + expect(capturedBody?.store).toBe(true); + expect(capturedBody).not.toHaveProperty('background'); + }); }); diff --git a/packages/openai-messages/src/handler.ts b/packages/openai-messages/src/handler.ts index 98c2f89d..3a348b31 100644 --- a/packages/openai-messages/src/handler.ts +++ b/packages/openai-messages/src/handler.ts @@ -340,58 +340,51 @@ const toToolDefinitions = (tools: FunctionTool[]): ToolDefinitionInput[] => * LaunchDarkly UI offers the Chat Completions parameter set, and the Responses API this handler * calls does not accept either of those two names. * + * The rule for this list: exclude a key only if setting it would BREAK the handler (wrong or + * missing result, or a request the handler cannot build); forward everything else the API + * accepts, even settings with no obvious generation effect (`store`, `user`, + * `safety_identifier`, `prompt_cache_key`, `prompt_cache_retention`, `include`, + * `context_management`, ...) — those are forwarded, not excluded, because a config that sets one + * still gets a working call. + * * Handler-owned (this handler sets these itself, from the config and the call shape, so a * `model.parameters` value must not be able to override what it already decided): `model`, * `input`, `tools`, `previous_response_id`, `text`. * - * Excluded (not a model setting — transport/plumbing this handler does not use): - * - `background` — async execution mode, not a generation setting. - * - `context_management` — server-side conversation compaction, not a generation setting. - * - `conversation` — this handler threads multi-turn state itself via `previous_response_id` and - * the composed message history, not via OpenAI's server-side conversation objects. - * - `include` — controls which extra fields the API echoes back in the response body. - * - `prompt` — references one of OpenAI's own stored prompt templates, which conflicts with the - * `input` this handler already builds from `config.messages` / `config.instructions`. - * - `prompt_cache_key`, `prompt_cache_retention` — cache bucketing/retention hints; no effect on - * model output. - * - `safety_identifier`, `user` (`user` is `safety_identifier`'s deprecated predecessor) — - * end-user identifiers for OpenAI's own abuse monitoring, not a generation setting. - * - `store` — whether OpenAI persists the response server-side for later retrieval, not a - * generation setting. - * - `stream` — this handler selects streaming by choosing between `responses.create()` and - * `responses.stream()`, not by setting a field, so a config value here would fight the method - * actually invoked. - * - `stream_options` — only meaningful alongside `stream: true`; same reasoning as `stream`. + * Excluded (would break the handler): + * - `stream`, `stream_options` — the handler chooses streaming itself, by calling + * `responses.create()` vs `responses.stream()`; a config value here fights that choice rather + * than configuring anything. + * - `background` — the call returns before the output exists, so the handler would get no result + * to return. + * - `conversation`, `prompt` — supply server-side conversation state / a stored prompt template + * that conflicts with the `input` this handler already builds from `config.messages` / + * `config.instructions` and threads itself via `previous_response_id`. */ const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'context_management', + 'include', 'instructions', 'max_output_tokens', 'metadata', 'moderation', 'parallel_tool_calls', + 'prompt_cache_key', + 'prompt_cache_retention', 'reasoning', + 'safety_identifier', 'service_tier', + 'store', 'temperature', 'tool_choice', 'top_logprobs', 'top_p', 'truncation', + 'user', ] as const; type OpenAIHandlerOwnedKeys = 'input' | 'model' | 'previous_response_id' | 'text' | 'tools'; -type OpenAIExcludedKeys = - | 'background' - | 'context_management' - | 'conversation' - | 'include' - | 'prompt' - | 'prompt_cache_key' - | 'prompt_cache_retention' - | 'safety_identifier' - | 'store' - | 'stream' - | 'stream_options' - | 'user'; +type OpenAIExcludedKeys = 'background' | 'conversation' | 'prompt' | 'stream' | 'stream_options'; // If a key of ResponseCreateParamsBase is added to the SDK and not classified above as forwarded, // handler-owned, or excluded, this type resolves to something other than `never` and the // assignment below fails to compile, naming the unclassified key. From d2a52ae1a89569f6fff7b1219b4c18bb458cb84c Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Fri, 2 Oct 2026 16:42:44 -0400 Subject: [PATCH 14/23] fix(vercel-messages): map model.parameters onto AI SDK call settings The handler spread model.parameters into generateText/streamText as saved, but the AI SDK only reads its own camelCase call settings and drops everything else without an error. A config's max_tokens, top_p, stop_sequences and the like never reached the model; only single-word keys such as temperature did. The handler now renames max_tokens / max_completion_tokens / max_output_tokens to maxOutputTokens and reasoning_effort to reasoning, camelizes the rest of the top-level keys, and forwards only the AI SDK call settings (LanguageModelCallOptions and RequestOptions, minus abortSignal, plus toolChoice and providerOptions). The allowlist is checked at compile time against the AI SDK's own types, and replaces the old denylist of handler-owned names. vercelEvaluate also forwards the three request fields experimental_evaluate accepts (maxRetries, headers, providerOptions) from model.parameters; options passed to vercelEvaluate win. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/utils.ts | 5 + .../src/__tests__/evaluate.test.ts | 43 +++++++ .../src/__tests__/handler.test.ts | 110 ++++++++++++++++++ .../src/__tests__/wire.test.ts | 56 +++++++++ packages/vercel-messages/src/evaluate.ts | 3 + packages/vercel-messages/src/handler.ts | 29 +---- .../vercel-messages/src/model-parameters.ts | 107 +++++++++++++++++ 7 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 packages/vercel-messages/src/__tests__/wire.test.ts create mode 100644 packages/vercel-messages/src/model-parameters.ts diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index efa50ee3..a89a407f 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -567,6 +567,11 @@ export function normalizeModelParameters(parameters: unknown): Record { ); }); + it('forwards the model.parameters evaluate accepts and drops generation settings it does not', async () => { + serverMocks.inspectConfig.mockResolvedValue({ + enabled: true, + config: { + ...config, + model: { + ...config.model, + parameters: { + max_retries: 2, + headers: { 'x-config': '1' }, + provider_options: { typesafe: { rounding_mode: 'nearest' } }, + temperature: 0.2, + max_tokens: 10, + abort_signal: 'bad', + }, + }, + }, + meta, + }); + await vercelEvaluate('flag', 'state', context, { questions }); + const request = aiMocks.experimental_evaluate.mock.calls[0][0]; + expect(request).toMatchObject({ + maxRetries: 2, + headers: { 'x-config': '1' }, + providerOptions: { typesafe: { rounding_mode: 'nearest' } }, + }); + for (const key of ['temperature', 'max_tokens', 'maxOutputTokens', 'abortSignal', 'abort_signal', 'max_retries']) { + expect(request).not.toHaveProperty(key); + } + }); + + it('lets options passed to vercelEvaluate win over model.parameters', async () => { + serverMocks.inspectConfig.mockResolvedValue({ + enabled: true, + config: { ...config, model: { ...config.model, parameters: { max_retries: 2, headers: { 'x-config': '1' } } } }, + meta, + }); + await vercelEvaluate('flag', 'state', context, { questions, maxRetries: 0, headers: { 'x-caller': '1' } }); + expect(aiMocks.experimental_evaluate).toHaveBeenCalledWith( + expect.objectContaining({ maxRetries: 0, headers: { 'x-caller': '1' } }), + ); + }); + it('uses evaluated provider telemetry, preserves the model id, and disables runtime telemetry', async () => { await vercelEvaluate('flag', 'state', context, { questions }); expect(spanMocks.startActiveSpan).toHaveBeenCalledWith('evaluate', expect.any(Function)); diff --git a/packages/vercel-messages/src/__tests__/handler.test.ts b/packages/vercel-messages/src/__tests__/handler.test.ts index 9718bf2d..1bbc7ecb 100644 --- a/packages/vercel-messages/src/__tests__/handler.test.ts +++ b/packages/vercel-messages/src/__tests__/handler.test.ts @@ -189,6 +189,116 @@ describe('createVercelMessagesHandler', () => { expect(request).not.toHaveProperty('output_format'); }); + it('maps snake_case model.parameters onto the AI SDK call settings generateText reads', async () => { + const parameters = { + max_tokens: 256, + top_p: 0.9, + top_k: 40, + presence_penalty: 0.1, + frequency_penalty: 0.2, + stop_sequences: ['END'], + seed: 7, + reasoning_effort: 'low', + max_retries: 1, + tool_choice: 'auto', + provider_options: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, + }; + await createVercelMessagesHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'hello'); + const request = aiMocks.generateText.mock.calls[0][0]; + expect(request).toMatchObject({ + maxOutputTokens: 256, + topP: 0.9, + topK: 40, + presencePenalty: 0.1, + frequencyPenalty: 0.2, + stopSequences: ['END'], + seed: 7, + reasoning: 'low', + maxRetries: 1, + toolChoice: 'auto', + // Nested values reach the provider untouched, snake_case and all. + providerOptions: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, + }); + for (const key of Object.keys(parameters).filter((k) => k.includes('_'))) { + expect(request).not.toHaveProperty(key); + } + expect(request).not.toHaveProperty('maxTokens'); + expect(request).not.toHaveProperty('reasoningEffort'); + }); + + it('maps model.parameters onto streamText the same way', async () => { + aiMocks.streamText.mockReturnValue({ + textStream: (async function* () { + yield 'ok'; + })(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }), + }); + const parameters = { max_tokens: 64, top_p: 0.5, abort_signal: 'bad', stream: false }; + await collect( + createVercelMessagesHandler().stream?.( + { ...baseConfig, model: { ...baseConfig.model, parameters } } as any, + 'q', + {}, + {}, + ) as AsyncIterable, + ); + const request = aiMocks.streamText.mock.calls[0][0]; + expect(request).toMatchObject({ maxOutputTokens: 64, topP: 0.5 }); + expect(request).not.toHaveProperty('max_tokens'); + expect(request).not.toHaveProperty('abortSignal'); + expect(request).not.toHaveProperty('stream'); + }); + + it('resolves the token-limit spellings to maxOutputTokens, explicit max_output_tokens first', async () => { + const run = async (parameters: Record) => { + aiMocks.generateText.mockClear(); + await createVercelMessagesHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'q'); + return aiMocks.generateText.mock.calls[0][0].maxOutputTokens; + }; + expect(await run({ max_tokens: 1, max_completion_tokens: 2, max_output_tokens: 3 })).toBe(3); + expect(await run({ max_tokens: 1, max_completion_tokens: 2 })).toBe(2); + expect(await run({ max_tokens: 1 })).toBe(1); + // Both spellings of one key set: snake_case wins, as in every other handler. + expect(await run({ maxOutputTokens: 9, max_tokens: 1 })).toBe(1); + expect(await run({ maxOutputTokens: 9 })).toBe(9); + }); + + it('prefers an explicit reasoning over reasoning_effort', async () => { + await createVercelMessagesHandler()( + { + ...baseConfig, + model: { ...baseConfig.model, parameters: { reasoning: 'high', reasoning_effort: 'low' } }, + } as any, + 'q', + ); + expect(aiMocks.generateText.mock.calls[0][0].reasoning).toBe('high'); + }); + + it('does not forward keys that are not AI SDK call settings', async () => { + const parameters = { + temperature: 0.3, + allowSystemInMessages: true, + allow_system_in_messages: true, + experimental_telemetry: { isEnabled: true }, + unknown_setting: 1, + abortSignal: 'bad', + }; + await createVercelMessagesHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'q'); + const request = aiMocks.generateText.mock.calls[0][0]; + expect(request.temperature).toBe(0.3); + for (const key of [ + 'allowSystemInMessages', + 'allow_system_in_messages', + 'experimental_telemetry', + 'experimentalTelemetry', + 'unknown_setting', + 'unknownSetting', + 'abortSignal', + ]) { + expect(request).not.toHaveProperty(key); + } + }); + it('uses generateText messages rather than a prompt string', async () => { await createVercelMessagesHandler()(baseConfig as any, 'hello'); const request = aiMocks.generateText.mock.calls[0][0]; diff --git a/packages/vercel-messages/src/__tests__/wire.test.ts b/packages/vercel-messages/src/__tests__/wire.test.ts new file mode 100644 index 00000000..de3f413e --- /dev/null +++ b/packages/vercel-messages/src/__tests__/wire.test.ts @@ -0,0 +1,56 @@ +import { MockLanguageModelV4 } from 'ai/test'; +import { describe, expect, it } from 'vitest'; +import { createVercelMessagesHandler } from '../handler.js'; + +// This file deliberately does NOT mock `ai`. The handler tests mock generateText/streamText, so +// they only prove what the handler hands to the AI SDK, not that the AI SDK reads it: it silently +// drops any call setting it does not recognize, which is how snake_case `max_tokens` / `top_p` +// went nowhere before. Running the real generateText/streamText against a mock language model +// checks the settings that actually reach the model call. + +const parameters = { + max_tokens: 321, + top_p: 0.42, + stop_sequences: ['END'], + max_retries: 0, +}; + +const baseConfig = { + model: { name: 'openai/gpt-5', parameters }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', +}; + +function capturingModel() { + return new MockLanguageModelV4({ + doGenerate: async () => { + throw new Error('aborted: request captured'); + }, + doStream: async () => { + throw new Error('aborted: request captured'); + }, + }); +} + +describe('createVercelMessagesHandler, model call (real AI SDK, mock model)', () => { + it('generateText sends model.parameters to the model as maxOutputTokens / topP / stopSequences', async () => { + const model = capturingModel(); + await expect(createVercelMessagesHandler({ model })(baseConfig as any, 'hi')).rejects.toThrow(); + expect(model.doGenerateCalls).toHaveLength(1); + expect(model.doGenerateCalls[0]).toMatchObject({ maxOutputTokens: 321, topP: 0.42, stopSequences: ['END'] }); + }); + + it('streamText sends the same settings', async () => { + const model = capturingModel(); + const stream = createVercelMessagesHandler({ model }).stream?.(baseConfig as any, 'hi', {}, {}); + await expect( + (async () => { + for await (const _event of stream as AsyncIterable) { + // drain + } + })(), + ).rejects.toThrow(); + expect(model.doStreamCalls).toHaveLength(1); + expect(model.doStreamCalls[0]).toMatchObject({ maxOutputTokens: 321, topP: 0.42, stopSequences: ['END'] }); + }); +}); diff --git a/packages/vercel-messages/src/evaluate.ts b/packages/vercel-messages/src/evaluate.ts index fe3e23f5..c2c20d41 100644 --- a/packages/vercel-messages/src/evaluate.ts +++ b/packages/vercel-messages/src/evaluate.ts @@ -16,6 +16,7 @@ import { import { SpanStatusCode, trace } from '@opentelemetry/api'; import { type Experimental_EvaluationModel, type Experimental_EvaluationQuestion, experimental_evaluate } from 'ai'; import { gatewayModelId } from './model-id.js'; +import { buildEvaluateParameterOptions } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-vercel-messages'; @@ -115,6 +116,8 @@ export async function vercelEvaluate LanguageModel | Promise); export interface VercelMessagesOptions extends ContentCaptureOptions { @@ -88,15 +74,6 @@ function servingProvider(config: AiConfigRep): string { return (config.provider?.name || 'unknown').toLowerCase(); } -function modelSettings(config: AiConfigRep): Record { - return Object.fromEntries( - Object.entries(config.model.parameters ?? {}).filter(([key]) => { - const normalized = key.replaceAll('_', '').replaceAll('-', '').toLowerCase(); - return !OWNED_PARAMETER_NAMES.has(normalized); - }), - ); -} - async function resolveModel(config: AiConfigRep, options: VercelMessagesOptions): Promise { if (options.modelFactory) return options.modelFactory(config); if (options.model) return options.model; @@ -300,7 +277,7 @@ export function createVercelMessagesHandler(options: VercelMessagesOptions = {}) }); const output = requestOutput(configRep.outputFormat); const result = await generateText({ - ...modelSettings(configRep), + ...buildModelParameterOptions(configRep.model.parameters), model: await resolveModel(configRep, options), messages, ...(system ? { system } : {}), @@ -349,7 +326,7 @@ export function createVercelMessagesHandler(options: VercelMessagesOptions = {}) }); chatSpan = startChatSpan(configRep, rootSpan); const result = streamText({ - ...modelSettings(configRep), + ...buildModelParameterOptions(configRep.model.parameters), model: await resolveModel(configRep, options), messages, ...(system ? { system } : {}), diff --git a/packages/vercel-messages/src/model-parameters.ts b/packages/vercel-messages/src/model-parameters.ts new file mode 100644 index 00000000..d8ab0101 --- /dev/null +++ b/packages/vercel-messages/src/model-parameters.ts @@ -0,0 +1,107 @@ +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; +import type { experimental_evaluate, generateText, LanguageModelCallOptions, RequestOptions } from 'ai'; + +/** + * AI SDK call settings this package's `generateText` / `streamText` calls take from + * `config.model.parameters`, after the renames in `buildModelParameterOptions`. + * + * The AI SDK drops any call setting it does not recognize without an error, so a key that is + * not spelled exactly as below (snake_case `max_tokens`, `top_p`) reaches nothing. This list is + * every `LanguageModelCallOptions` and `RequestOptions` field, minus the exclusions below, plus + * the two `generateText` fields a config can meaningfully set as JSON: `toolChoice` and + * `providerOptions`. + * + * Handler-owned (set by the handler from the config and the call shape, never from + * `model.parameters`): `model`, `messages`, `prompt`, `system`, `instructions`, `tools`, + * `stopWhen`, `output`. + * + * Excluded: `abortSignal`, which cannot be expressed in a JSON config and belongs to the caller. + * Every other `generateText` field (callbacks, telemetry, tool repair, approval, `activeTools`, + * `include`, ...) is either a function or wiring this handler does not expose, so it is not + * forwarded. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'frequencyPenalty', + 'headers', + 'maxOutputTokens', + 'maxRetries', + 'presencePenalty', + 'providerOptions', + 'reasoning', + 'seed', + 'stopSequences', + 'temperature', + 'timeout', + 'toolChoice', + 'topK', + 'topP', +] as const satisfies ReadonlyArray[0]>; + +type VercelExcludedKeys = 'abortSignal'; +// If the AI SDK adds a call setting and it is not classified above as forwarded or excluded, +// this type resolves to something other than `never` and the assignment below fails to compile, +// naming the unclassified key. +type VercelUndecidedModelParameterKeys = Exclude< + keyof LanguageModelCallOptions | keyof RequestOptions, + VercelExcludedKeys | (typeof FORWARDED_MODEL_PARAMETER_KEYS)[number] +>; +const _vercelModelParameterKeysExhaustive: Record = {} as Record< + never, + never +>; + +/** + * `config.model.parameters` as AI SDK call settings. The LaunchDarkly UI writes provider names + * in snake_case, so the bag is camelized (`top_p` becomes `topP`; nested values are left alone, + * and snake_case wins when both spellings are set), after two renames to the AI SDK's own names: + * + * - `max_tokens` / `max_completion_tokens` / `max_output_tokens` become `maxOutputTokens`. An + * explicit `max_output_tokens` wins, then `max_completion_tokens`, then `max_tokens`. + * - `reasoning_effort` (OpenAI's name) becomes `reasoning`, whose values it shares. An explicit + * `reasoning` wins. + * + * Only `FORWARDED_MODEL_PARAMETER_KEYS` survive. A config that sets none of them produces `{}`, + * so the call sees exactly what it always has. + */ +export function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + const { max_tokens, max_completion_tokens, max_output_tokens, reasoning_effort, ...rest } = + normalizeModelParameters(parameters); + const renamed: Record = { ...rest }; + const maxOutputTokens = max_output_tokens ?? max_completion_tokens ?? max_tokens; + if (maxOutputTokens !== undefined) renamed.max_output_tokens = maxOutputTokens; + if (reasoning_effort !== undefined && renamed.reasoning === undefined) renamed.reasoning = reasoning_effort; + return pickForwardedModelParameters(camelizeModelParameters(renamed), FORWARDED_MODEL_PARAMETER_KEYS); +} + +/** + * `experimental_evaluate` request fields `vercelEvaluate` takes from `config.model.parameters`. + * The evaluate call accepts no generation settings at all (no temperature, no token limit), so + * those are not forwarded here. + * + * Handler-owned: `model`, `state`, `questions`. Excluded: `abortSignal`, as above. + */ +const FORWARDED_EVALUATE_PARAMETER_KEYS = ['headers', 'maxRetries', 'providerOptions'] as const; + +type EvaluateHandlerOwnedKeys = 'model' | 'questions' | 'state'; +type EvaluateExcludedKeys = 'abortSignal'; +type EvaluateUndecidedModelParameterKeys = Exclude< + keyof Parameters[0], + EvaluateHandlerOwnedKeys | EvaluateExcludedKeys | (typeof FORWARDED_EVALUATE_PARAMETER_KEYS)[number] +>; +const _evaluateModelParameterKeysExhaustive: Record = {} as Record< + never, + never +>; + +/** `config.model.parameters`, camelized, narrowed to what `experimental_evaluate` accepts. */ +export function buildEvaluateParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + return pickForwardedModelParameters( + camelizeModelParameters(normalizeModelParameters(parameters)), + FORWARDED_EVALUATE_PARAMETER_KEYS, + ); +} From 4241d754d5caedf51f8eb2b2fc2301ad678c61eb Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Fri, 2 Oct 2026 16:42:45 -0400 Subject: [PATCH 15/23] fix(vercel-agents): map model.parameters onto ToolLoopAgent call settings Same problem as vercel-messages: the ToolLoopAgent settings were built by spreading model.parameters as saved, so snake_case keys such as max_tokens and top_p were dropped by the AI SDK. The single-agent handler and every native-graph node now go through the same mapping and compile-time-checked allowlist. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/handler.test.ts | 81 +++++++++++++++++++ .../src/__tests__/native-graph.test.ts | 28 +++++++ .../vercel-agents/src/__tests__/wire.test.ts | 31 +++++++ packages/vercel-agents/src/handler.ts | 27 +------ .../vercel-agents/src/model-parameters.ts | 79 ++++++++++++++++++ packages/vercel-agents/src/native-graph.ts | 27 +------ 6 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 packages/vercel-agents/src/__tests__/wire.test.ts create mode 100644 packages/vercel-agents/src/model-parameters.ts diff --git a/packages/vercel-agents/src/__tests__/handler.test.ts b/packages/vercel-agents/src/__tests__/handler.test.ts index 70cc125b..c018f191 100644 --- a/packages/vercel-agents/src/__tests__/handler.test.ts +++ b/packages/vercel-agents/src/__tests__/handler.test.ts @@ -179,6 +179,87 @@ describe('createVercelAgentsHandler', () => { }); }); + it('maps snake_case model.parameters onto the ToolLoopAgent call settings', async () => { + const parameters = { + max_tokens: 256, + top_p: 0.9, + top_k: 40, + presence_penalty: 0.1, + frequency_penalty: 0.2, + stop_sequences: ['END'], + seed: 7, + reasoning_effort: 'low', + max_retries: 1, + tool_choice: 'auto', + provider_options: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, + }; + await createVercelAgentsHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'hello'); + const settings = aiMocks.agentArguments[0]; + expect(settings).toMatchObject({ + maxOutputTokens: 256, + topP: 0.9, + topK: 40, + presencePenalty: 0.1, + frequencyPenalty: 0.2, + stopSequences: ['END'], + seed: 7, + reasoning: 'low', + maxRetries: 1, + toolChoice: 'auto', + // Nested values reach the provider untouched, snake_case and all. + providerOptions: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, + }); + for (const key of Object.keys(parameters).filter((k) => k.includes('_'))) { + expect(settings).not.toHaveProperty(key); + } + }); + + it('applies the same mapping on the streaming path', async () => { + aiMocks.stream.mockReturnValue({ + textStream: (async function* () { + yield 'ok'; + })(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }), + }); + const parameters = { max_tokens: 64, maxOutputTokens: 9, top_p: 0.5, abort_signal: 'bad' }; + await collect( + createVercelAgentsHandler().stream?.( + { ...baseConfig, model: { ...baseConfig.model, parameters } } as any, + 'q', + {}, + {}, + ) as AsyncIterable, + ); + const settings = aiMocks.agentArguments[0]; + // Both spellings of one key set: snake_case wins, as in every other handler. + expect(settings).toMatchObject({ maxOutputTokens: 64, topP: 0.5 }); + expect(settings).not.toHaveProperty('max_tokens'); + expect(settings).not.toHaveProperty('abortSignal'); + }); + + it('does not forward keys that are not AI SDK call settings', async () => { + const parameters = { + temperature: 0.3, + allowSystemInMessages: true, + experimental_telemetry: { isEnabled: true }, + unknown_setting: 1, + abortSignal: 'bad', + }; + await createVercelAgentsHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'q'); + const settings = aiMocks.agentArguments[0]; + expect(settings.temperature).toBe(0.3); + for (const key of [ + 'allowSystemInMessages', + 'experimental_telemetry', + 'experimentalTelemetry', + 'unknown_setting', + 'unknownSetting', + 'abortSignal', + ]) { + expect(settings).not.toHaveProperty(key); + } + }); + it('passes structured history and user input to agent.generate messages', async () => { const history = [ { role: 'system' as const, content: 'filtered system' }, diff --git a/packages/vercel-agents/src/__tests__/native-graph.test.ts b/packages/vercel-agents/src/__tests__/native-graph.test.ts index 02962285..5830bca6 100644 --- a/packages/vercel-agents/src/__tests__/native-graph.test.ts +++ b/packages/vercel-agents/src/__tests__/native-graph.test.ts @@ -161,6 +161,34 @@ describe('toVercelAgents', () => { ); }); + it("maps each node's snake_case model.parameters onto that node's ToolLoopAgent call settings", async () => { + const graph = makeGraph(); + (graph.root.config.model as any).parameters = { + max_tokens: 128, + top_p: 0.7, + provider_options: { openai: { reasoning_summary: 'auto' } }, + instructions: 'bad', + tools: {}, + abort_signal: 'bad', + }; + (graph.getNode('leaf')!.config.model as any).parameters = { temperature: 0.1, max_completion_tokens: 32 }; + await toVercelAgents(Promise.resolve(graph as any)).invoke('hi'); + const rootSettings = aiMocks.agentArguments.find((options) => options.model === 'openai/root-model'); + const leafSettings = aiMocks.agentArguments.find((options) => options.model === 'anthropic/leaf-model'); + expect(rootSettings).toMatchObject({ + maxOutputTokens: 128, + topP: 0.7, + providerOptions: { openai: { reasoning_summary: 'auto' } }, + instructions: 'Root instructions', + }); + expect(Object.keys(rootSettings.tools)).toEqual(['transfer_to_leaf']); + for (const key of ['max_tokens', 'top_p', 'provider_options', 'abort_signal', 'abortSignal']) { + expect(rootSettings).not.toHaveProperty(key); + } + expect(leafSettings).toMatchObject({ temperature: 0.1, maxOutputTokens: 32 }); + expect(leafSettings).not.toHaveProperty('max_completion_tokens'); + }); + it('resolves the injected model factory independently for every evaluated node', async () => { const modelFactory = vi.fn((config: any) => ({ modelId: `resolved:${config.model.name}` })); await toVercelAgents(Promise.resolve(makeGraph() as any), { modelFactory } as any).invoke('hi'); diff --git a/packages/vercel-agents/src/__tests__/wire.test.ts b/packages/vercel-agents/src/__tests__/wire.test.ts new file mode 100644 index 00000000..a1c89d11 --- /dev/null +++ b/packages/vercel-agents/src/__tests__/wire.test.ts @@ -0,0 +1,31 @@ +import { MockLanguageModelV4 } from 'ai/test'; +import { describe, expect, it } from 'vitest'; +import { createVercelAgentsHandler } from '../handler.js'; + +// This file deliberately does NOT mock `ai`. The handler tests mock ToolLoopAgent, so they only +// prove what the handler hands to the AI SDK, not that the AI SDK reads it: it silently drops any +// call setting it does not recognize, which is how snake_case `max_tokens` / `top_p` went nowhere +// before. Running a real ToolLoopAgent against a mock language model checks the settings that +// actually reach the model call. + +const baseConfig = { + model: { + name: 'openai/gpt-5', + parameters: { max_tokens: 321, top_p: 0.42, stop_sequences: ['END'], max_retries: 0 }, + }, + provider: { name: 'OpenAI' }, + instructions: 'You are an agent.', +}; + +describe('createVercelAgentsHandler, model call (real ToolLoopAgent, mock model)', () => { + it('sends model.parameters to the model as maxOutputTokens / topP / stopSequences', async () => { + const model = new MockLanguageModelV4({ + doGenerate: async () => { + throw new Error('aborted: request captured'); + }, + }); + await expect(createVercelAgentsHandler({ model })(baseConfig as any, 'hi')).rejects.toThrow(); + expect(model.doGenerateCalls).toHaveLength(1); + expect(model.doGenerateCalls[0]).toMatchObject({ maxOutputTokens: 321, topP: 0.42, stopSequences: ['END'] }); + }); +}); diff --git a/packages/vercel-agents/src/handler.ts b/packages/vercel-agents/src/handler.ts index 8b914f67..6fb6fd20 100644 --- a/packages/vercel-agents/src/handler.ts +++ b/packages/vercel-agents/src/handler.ts @@ -35,24 +35,10 @@ import { tool, } from 'ai'; import { gatewayModelId } from './model-id.js'; +import { buildModelParameterOptions } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-vercel-agents'; const MAX_STEPS = 10; -const OWNED_PARAMETER_NAMES = new Set([ - 'model', - 'messages', - 'prompt', - 'system', - 'instructions', - 'tools', - 'stream', - 'output', - 'outputformat', - 'stopwhen', - 'maxsteps', - 'apikey', - 'baseurl', -]); export interface VercelAgentsOptions extends ContentCaptureOptions { model?: LanguageModel; @@ -112,15 +98,6 @@ function servingProvider(config: AiConfigRep): string { return (config.provider?.name || 'unknown').toLowerCase(); } -function modelSettings(config: AiConfigRep): Record { - return Object.fromEntries( - Object.entries(config.model.parameters ?? {}).filter(([key]) => { - const normalized = key.replaceAll('_', '').replaceAll('-', '').toLowerCase(); - return !OWNED_PARAMETER_NAMES.has(normalized); - }), - ); -} - async function resolveModel(config: AiConfigRep, options: VercelAgentsOptions): Promise { if (options.modelFactory) return options.modelFactory(config); if (options.model) return options.model; @@ -272,7 +249,7 @@ async function buildAgent( ? Output.object({ schema: jsonSchema(normalizeOutputSchema(config.outputFormat)) }) : undefined; const agent = new ToolLoopAgent({ - ...modelSettings(config), + ...buildModelParameterOptions(config.model.parameters), model: await resolveModel(config, options), ...(system ? { instructions: system } : {}), ...(Object.keys(tools).length > 0 ? { tools } : {}), diff --git a/packages/vercel-agents/src/model-parameters.ts b/packages/vercel-agents/src/model-parameters.ts new file mode 100644 index 00000000..a01a8445 --- /dev/null +++ b/packages/vercel-agents/src/model-parameters.ts @@ -0,0 +1,79 @@ +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; +import type { LanguageModelCallOptions, RequestOptions, ToolLoopAgentSettings } from 'ai'; + +/** + * AI SDK call settings this package's `ToolLoopAgent`s (the single-agent handler and every + * native-graph node) take from `config.model.parameters`, after the renames in + * `buildModelParameterOptions`. + * + * The AI SDK drops any call setting it does not recognize without an error, so a key that is + * not spelled exactly as below (snake_case `max_tokens`, `top_p`) reaches nothing. This list is + * every `LanguageModelCallOptions` and `RequestOptions` field, minus the exclusions below, plus + * the two `ToolLoopAgent` settings a config can meaningfully set as JSON: `toolChoice` and + * `providerOptions`. + * + * Handler-owned (set by the handler from the config and the call shape, never from + * `model.parameters`): `model`, `instructions`, `tools`, `stopWhen`, `output`. + * + * Excluded: `abortSignal`, which cannot be expressed in a JSON config and which `ToolLoopAgent` + * only takes per call, not at construction. Every other `ToolLoopAgent` setting (callbacks, + * telemetry, tool repair, approval, `activeTools`, `include`, ...) is either a function or wiring + * this handler does not expose, so it is not forwarded. + */ +const FORWARDED_MODEL_PARAMETER_KEYS = [ + 'frequencyPenalty', + 'headers', + 'maxOutputTokens', + 'maxRetries', + 'presencePenalty', + 'providerOptions', + 'reasoning', + 'seed', + 'stopSequences', + 'temperature', + 'timeout', + 'toolChoice', + 'topK', + 'topP', +] as const satisfies ReadonlyArray; + +type VercelExcludedKeys = 'abortSignal'; +// If the AI SDK adds a call setting and it is not classified above as forwarded or excluded, +// this type resolves to something other than `never` and the assignment below fails to compile, +// naming the unclassified key. +type VercelUndecidedModelParameterKeys = Exclude< + keyof LanguageModelCallOptions | keyof RequestOptions, + VercelExcludedKeys | (typeof FORWARDED_MODEL_PARAMETER_KEYS)[number] +>; +const _vercelModelParameterKeysExhaustive: Record = {} as Record< + never, + never +>; + +/** + * `config.model.parameters` as AI SDK call settings. The LaunchDarkly UI writes provider names + * in snake_case, so the bag is camelized (`top_p` becomes `topP`; nested values are left alone, + * and snake_case wins when both spellings are set), after two renames to the AI SDK's own names: + * + * - `max_tokens` / `max_completion_tokens` / `max_output_tokens` become `maxOutputTokens`. An + * explicit `max_output_tokens` wins, then `max_completion_tokens`, then `max_tokens`. + * - `reasoning_effort` (OpenAI's name) becomes `reasoning`, whose values it shares. An explicit + * `reasoning` wins. + * + * Only `FORWARDED_MODEL_PARAMETER_KEYS` survive. A config that sets none of them produces `{}`, + * so the call sees exactly what it always has. + */ +export function buildModelParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { + const { max_tokens, max_completion_tokens, max_output_tokens, reasoning_effort, ...rest } = + normalizeModelParameters(parameters); + const renamed: Record = { ...rest }; + const maxOutputTokens = max_output_tokens ?? max_completion_tokens ?? max_tokens; + if (maxOutputTokens !== undefined) renamed.max_output_tokens = maxOutputTokens; + if (reasoning_effort !== undefined && renamed.reasoning === undefined) renamed.reasoning = reasoning_effort; + return pickForwardedModelParameters(camelizeModelParameters(renamed), FORWARDED_MODEL_PARAMETER_KEYS); +} diff --git a/packages/vercel-agents/src/native-graph.ts b/packages/vercel-agents/src/native-graph.ts index 9403640c..03d54abc 100644 --- a/packages/vercel-agents/src/native-graph.ts +++ b/packages/vercel-agents/src/native-graph.ts @@ -16,24 +16,10 @@ import { SpanStatusCode, trace } from '@opentelemetry/api'; import { jsonSchema, type LanguageModel, type ModelMessage, stepCountIs, ToolLoopAgent, type ToolSet, tool } from 'ai'; import type { VercelAgentsOptions } from './handler.js'; import { gatewayModelId } from './model-id.js'; +import { buildModelParameterOptions } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-vercel-agents'; const MAX_STEPS = 10; -const OWNED_PARAMETER_NAMES = new Set([ - 'model', - 'messages', - 'prompt', - 'system', - 'instructions', - 'tools', - 'stream', - 'output', - 'outputformat', - 'stopwhen', - 'maxsteps', - 'apikey', - 'baseurl', -]); export interface VercelNativeGraphOptions extends VercelAgentsOptions { toolHandlers?: Record; @@ -54,15 +40,6 @@ function normalizeUsage(usage: unknown): Usage { return { input, output, total: numberOrZero(raw.totalTokens ?? raw.total_tokens) || input + output }; } -function modelSettings(node: GraphNode): Record { - return Object.fromEntries( - Object.entries(node.config.model.parameters ?? {}).filter(([key]) => { - const normalized = key.replaceAll('_', '').replaceAll('-', '').toLowerCase(); - return !OWNED_PARAMETER_NAMES.has(normalized); - }), - ); -} - async function resolveModel(node: GraphNode, options: VercelNativeGraphOptions): Promise { if (options.modelFactory) return options.modelFactory(node.config); if (options.model) return options.model; @@ -181,7 +158,7 @@ export const toVercelAgents = ( agents.set( node.key, new ToolLoopAgent({ - ...modelSettings(node), + ...buildModelParameterOptions(node.config.model.parameters), model: await resolveModel(node, options), ...(nodeInstructions(node, variables) ? { instructions: nodeInstructions(node, variables) } : {}), ...(Object.keys(tools).length > 0 ? { tools } : {}), From 0a616fbfe6b7fcd3600f71da60959db935dca104 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:49:44 -0400 Subject: [PATCH 16/23] fix(vercel): never forward headers, providerOptions, maxRetries or timeout from model.parameters Call-level headers win over the provider's own, so a config that set headers.Authorization replaced the customer's API key on @ai-sdk/openai and the AI Gateway. providerOptions carries Gateway credentials (byok) and routing (order, only, models). maxRetries and timeout are client policy. vercelEvaluate no longer reads model.parameters at all, since nothing it accepts is left. --- .../src/__tests__/handler.test.ts | 5 - .../src/__tests__/model-parameters.test.ts | 119 +++++++++++++ .../src/__tests__/native-graph.test.ts | 16 +- .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ .../vercel-agents/src/__tests__/wire.test.ts | 2 +- .../vercel-agents/src/model-parameters.ts | 26 +-- .../src/__tests__/evaluate.test.ts | 34 ++-- .../src/__tests__/handler.test.ts | 5 - .../src/__tests__/model-parameters.test.ts | 119 +++++++++++++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ .../src/__tests__/wire.test.ts | 1 - packages/vercel-messages/src/evaluate.ts | 3 - .../vercel-messages/src/model-parameters.ts | 52 ++---- 13 files changed, 612 insertions(+), 92 deletions(-) create mode 100644 packages/vercel-agents/src/__tests__/model-parameters.test.ts create mode 100644 packages/vercel-agents/src/__tests__/never-forwarded.ts create mode 100644 packages/vercel-messages/src/__tests__/model-parameters.test.ts create mode 100644 packages/vercel-messages/src/__tests__/never-forwarded.ts diff --git a/packages/vercel-agents/src/__tests__/handler.test.ts b/packages/vercel-agents/src/__tests__/handler.test.ts index c018f191..b654d4e0 100644 --- a/packages/vercel-agents/src/__tests__/handler.test.ts +++ b/packages/vercel-agents/src/__tests__/handler.test.ts @@ -189,9 +189,7 @@ describe('createVercelAgentsHandler', () => { stop_sequences: ['END'], seed: 7, reasoning_effort: 'low', - max_retries: 1, tool_choice: 'auto', - provider_options: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, }; await createVercelAgentsHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'hello'); const settings = aiMocks.agentArguments[0]; @@ -204,10 +202,7 @@ describe('createVercelAgentsHandler', () => { stopSequences: ['END'], seed: 7, reasoning: 'low', - maxRetries: 1, toolChoice: 'auto', - // Nested values reach the provider untouched, snake_case and all. - providerOptions: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, }); for (const key of Object.keys(parameters).filter((k) => k.includes('_'))) { expect(settings).not.toHaveProperty(key); diff --git a/packages/vercel-agents/src/__tests__/model-parameters.test.ts b/packages/vercel-agents/src/__tests__/model-parameters.test.ts new file mode 100644 index 00000000..11fc9e4f --- /dev/null +++ b/packages/vercel-agents/src/__tests__/model-parameters.test.ts @@ -0,0 +1,119 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createGateway, generateText } from 'ai'; +import { MockLanguageModelV4 } from 'ai/test'; +import { describe, expect, it } from 'vitest'; +import { createVercelAgentsHandler } from '../handler.js'; +import { buildModelParameterOptions } from '../model-parameters.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; + +// This file deliberately does NOT mock `ai`: it checks what reaches the provider's HTTP request. + +type SeenHeaders = Record[]; + +function capturingFetch(seen: SeenHeaders): typeof globalThis.fetch { + return (async (_url: unknown, init?: RequestInit) => { + seen.push(Object.fromEntries(new Headers(init?.headers).entries())); + return new Response(JSON.stringify({ error: { message: 'captured' } }), { status: 401 }); + }) as typeof globalThis.fetch; +} + +const attackerParameters = { + headers: { Authorization: 'Bearer ATTACKER' }, + extra_headers: { Authorization: 'Bearer ATTACKER' }, + provider_options: { gateway: { byok: { openai: [{ apiKey: 'ATTACKER' }] }, order: ['attacker'] } }, +}; + +function config(parameters: Record) { + return { + model: { name: 'openai/gpt-4o', parameters }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }; +} + +describe('model.parameters cannot change how the request authenticates', () => { + it('keeps the customer API key on @ai-sdk/openai when a config sets an Authorization header', async () => { + const seen: SeenHeaders = []; + const provider = createOpenAI({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + await expect( + createVercelAgentsHandler({ model: provider('gpt-4o') })(config(attackerParameters) as any, 'hi'), + ).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers.authorization).toBe('Bearer CUSTOMER'); + } + }); + + it('keeps the customer API key on the Vercel AI Gateway when a config sets an Authorization header', async () => { + const seen: SeenHeaders = []; + const gateway = createGateway({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + await expect( + createVercelAgentsHandler({ model: gateway('openai/gpt-4o') })(config(attackerParameters) as any, 'hi'), + ).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers.authorization).toBe('Bearer CUSTOMER'); + } + }); + + // Jeff's repro from the review, inverted: the options built from a config that sets + // `headers.Authorization` no longer replace the provider's own key. + it('buildModelParameterOptions output spread into generateText does not override the provider key', async () => { + const seen: SeenHeaders = []; + const provider = createOpenAI({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + const opts = buildModelParameterOptions({ headers: { Authorization: 'Bearer ATTACKER' } } as never); + await generateText({ model: provider('gpt-4o'), prompt: 'hi', maxRetries: 0, ...opts }).catch(() => {}); + expect(seen[0]?.authorization).toBe('Bearer CUSTOMER'); + }); +}); + +describe('buildModelParameterOptions allowlist', () => { + it('drops every never-forwarded key', () => { + const options = buildModelParameterOptions({ ...NEVER_FORWARDED_PARAMETERS, temperature: 0.5 } as never); + expect(options).toEqual({ temperature: 0.5 }); + expectNoNeverForwardedValue(options); + }); + + it('never-forwarded keys do not reach the model call', async () => { + const model = new MockLanguageModelV4({ + doGenerate: async () => { + throw new Error('aborted: request captured'); + }, + }); + await expect( + createVercelAgentsHandler({ model })(config(NEVER_FORWARDED_PARAMETERS) as any, 'hi'), + ).rejects.toThrow(); + expect(model.doGenerateCalls).toHaveLength(1); + // The AI SDK sets its own `headers` (user agent) on every call; the deep check still proves + // none of the config's values reached it. + expectNoNeverForwardedValue(model.doGenerateCalls[0], ['headers']); + }); + + it('forwards the allowed keys under the AI SDK names', () => { + expect( + buildModelParameterOptions({ + temperature: 0.2, + top_p: 0.9, + top_k: 40, + presence_penalty: 0.1, + frequency_penalty: 0.3, + stop_sequences: ['END'], + seed: 7, + max_tokens: 100, + reasoning_effort: 'low', + tool_choice: 'auto', + } as never), + ).toEqual({ + temperature: 0.2, + topP: 0.9, + topK: 40, + presencePenalty: 0.1, + frequencyPenalty: 0.3, + stopSequences: ['END'], + seed: 7, + maxOutputTokens: 100, + reasoning: 'low', + toolChoice: 'auto', + }); + }); +}); diff --git a/packages/vercel-agents/src/__tests__/native-graph.test.ts b/packages/vercel-agents/src/__tests__/native-graph.test.ts index 5830bca6..10444426 100644 --- a/packages/vercel-agents/src/__tests__/native-graph.test.ts +++ b/packages/vercel-agents/src/__tests__/native-graph.test.ts @@ -69,6 +69,7 @@ vi.mock('@opentelemetry/api', async (importOriginal) => { }); import { toVercelAgents } from '../native-graph.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; function makeNode( key: string, @@ -178,17 +179,28 @@ describe('toVercelAgents', () => { expect(rootSettings).toMatchObject({ maxOutputTokens: 128, topP: 0.7, - providerOptions: { openai: { reasoning_summary: 'auto' } }, instructions: 'Root instructions', }); expect(Object.keys(rootSettings.tools)).toEqual(['transfer_to_leaf']); - for (const key of ['max_tokens', 'top_p', 'provider_options', 'abort_signal', 'abortSignal']) { + for (const key of ['max_tokens', 'top_p', 'provider_options', 'providerOptions', 'abort_signal', 'abortSignal']) { expect(rootSettings).not.toHaveProperty(key); } expect(leafSettings).toMatchObject({ temperature: 0.1, maxOutputTokens: 32 }); expect(leafSettings).not.toHaveProperty('max_completion_tokens'); }); + it('never forwards credentials, endpoints, headers or host settings from any node', async () => { + const graph = makeGraph(); + (graph.root.config.model as any).parameters = { ...NEVER_FORWARDED_PARAMETERS, temperature: 0.3 }; + (graph.getNode('leaf')!.config.model as any).parameters = NEVER_FORWARDED_PARAMETERS; + await toVercelAgents(Promise.resolve(graph as any)).invoke('hi'); + const rootSettings = aiMocks.agentArguments.find((options) => options.model === 'openai/root-model'); + expect(rootSettings.temperature).toBe(0.3); + for (const settings of aiMocks.agentArguments) { + expectNoNeverForwardedValue(settings); + } + }); + it('resolves the injected model factory independently for every evaluated node', async () => { const modelFactory = vi.fn((config: any) => ({ modelId: `resolved:${config.model.name}` })); await toVercelAgents(Promise.resolve(makeGraph() as any), { modelFactory } as any).invoke('hi'); diff --git a/packages/vercel-agents/src/__tests__/never-forwarded.ts b/packages/vercel-agents/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/vercel-agents/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/vercel-agents/src/__tests__/wire.test.ts b/packages/vercel-agents/src/__tests__/wire.test.ts index a1c89d11..917ee57d 100644 --- a/packages/vercel-agents/src/__tests__/wire.test.ts +++ b/packages/vercel-agents/src/__tests__/wire.test.ts @@ -11,7 +11,7 @@ import { createVercelAgentsHandler } from '../handler.js'; const baseConfig = { model: { name: 'openai/gpt-5', - parameters: { max_tokens: 321, top_p: 0.42, stop_sequences: ['END'], max_retries: 0 }, + parameters: { max_tokens: 321, top_p: 0.42, stop_sequences: ['END'] }, }, provider: { name: 'OpenAI' }, instructions: 'You are an agent.', diff --git a/packages/vercel-agents/src/model-parameters.ts b/packages/vercel-agents/src/model-parameters.ts index a01a8445..3e23ec34 100644 --- a/packages/vercel-agents/src/model-parameters.ts +++ b/packages/vercel-agents/src/model-parameters.ts @@ -13,36 +13,38 @@ import type { LanguageModelCallOptions, RequestOptions, ToolLoopAgentSettings } * * The AI SDK drops any call setting it does not recognize without an error, so a key that is * not spelled exactly as below (snake_case `max_tokens`, `top_p`) reaches nothing. This list is - * every `LanguageModelCallOptions` and `RequestOptions` field, minus the exclusions below, plus - * the two `ToolLoopAgent` settings a config can meaningfully set as JSON: `toolChoice` and - * `providerOptions`. + * the `LanguageModelCallOptions` generation settings plus `toolChoice`. * * Handler-owned (set by the handler from the config and the call shape, never from * `model.parameters`): `model`, `instructions`, `tools`, `stopWhen`, `output`. * - * Excluded: `abortSignal`, which cannot be expressed in a JSON config and which `ToolLoopAgent` - * only takes per call, not at construction. Every other `ToolLoopAgent` setting (callbacks, - * telemetry, tool repair, approval, `activeTools`, `include`, ...) is either a function or wiring - * this handler does not expose, so it is not forwarded. + * Never forwarded, because they configure the request rather than the model and a config must not + * be able to change them: + * - `headers`: call-level headers win over the provider's own, so a config could replace the + * customer's `Authorization` header. + * - `providerOptions`: the Vercel AI Gateway reads credentials (`gateway.byok`) and routing + * (`gateway.order`, `gateway.only`, `gateway.models`) from it. + * - `maxRetries`, `timeout`: client retry and timeout policy. + * - `abortSignal`: cannot be expressed in a JSON config and belongs to the caller. + * + * Every other `ToolLoopAgent` setting (callbacks, telemetry, tool repair, approval, `activeTools`, + * `include`, ...) is either a function or wiring this handler does not expose, so it is not + * forwarded either. */ const FORWARDED_MODEL_PARAMETER_KEYS = [ 'frequencyPenalty', - 'headers', 'maxOutputTokens', - 'maxRetries', 'presencePenalty', - 'providerOptions', 'reasoning', 'seed', 'stopSequences', 'temperature', - 'timeout', 'toolChoice', 'topK', 'topP', ] as const satisfies ReadonlyArray; -type VercelExcludedKeys = 'abortSignal'; +type VercelExcludedKeys = 'abortSignal' | 'headers' | 'maxRetries' | 'providerOptions' | 'timeout'; // If the AI SDK adds a call setting and it is not classified above as forwarded or excluded, // this type resolves to something other than `never` and the assignment below fails to compile, // naming the unclassified key. diff --git a/packages/vercel-messages/src/__tests__/evaluate.test.ts b/packages/vercel-messages/src/__tests__/evaluate.test.ts index 5013aea1..f51c4b85 100644 --- a/packages/vercel-messages/src/__tests__/evaluate.test.ts +++ b/packages/vercel-messages/src/__tests__/evaluate.test.ts @@ -57,6 +57,7 @@ vi.mock('@opentelemetry/api', async (importOriginal) => { }); import { vercelEvaluate } from '../evaluate.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; const questions = { refunded: { type: 'boolean' as const, instructions: 'Was a refund issued?' }, @@ -167,43 +168,28 @@ describe('vercelEvaluate', () => { ); }); - it('forwards the model.parameters evaluate accepts and drops generation settings it does not', async () => { + it('does not forward model.parameters to experimental_evaluate', async () => { + // experimental_evaluate takes no generation settings, and the request settings it does take + // (headers, maxRetries, providerOptions) are never read from a config: call-level headers + // replace the provider's Authorization header, and the Gateway reads credentials and routing + // from providerOptions. serverMocks.inspectConfig.mockResolvedValue({ enabled: true, config: { ...config, - model: { - ...config.model, - parameters: { - max_retries: 2, - headers: { 'x-config': '1' }, - provider_options: { typesafe: { rounding_mode: 'nearest' } }, - temperature: 0.2, - max_tokens: 10, - abort_signal: 'bad', - }, - }, + model: { ...config.model, parameters: { ...NEVER_FORWARDED_PARAMETERS, temperature: 0.2, max_tokens: 10 } }, }, meta, }); await vercelEvaluate('flag', 'state', context, { questions }); const request = aiMocks.experimental_evaluate.mock.calls[0][0]; - expect(request).toMatchObject({ - maxRetries: 2, - headers: { 'x-config': '1' }, - providerOptions: { typesafe: { rounding_mode: 'nearest' } }, - }); - for (const key of ['temperature', 'max_tokens', 'maxOutputTokens', 'abortSignal', 'abort_signal', 'max_retries']) { + expectNoNeverForwardedValue(request); + for (const key of ['temperature', 'max_tokens', 'maxOutputTokens']) { expect(request).not.toHaveProperty(key); } }); - it('lets options passed to vercelEvaluate win over model.parameters', async () => { - serverMocks.inspectConfig.mockResolvedValue({ - enabled: true, - config: { ...config, model: { ...config.model, parameters: { max_retries: 2, headers: { 'x-config': '1' } } } }, - meta, - }); + it('passes options given to vercelEvaluate through', async () => { await vercelEvaluate('flag', 'state', context, { questions, maxRetries: 0, headers: { 'x-caller': '1' } }); expect(aiMocks.experimental_evaluate).toHaveBeenCalledWith( expect.objectContaining({ maxRetries: 0, headers: { 'x-caller': '1' } }), diff --git a/packages/vercel-messages/src/__tests__/handler.test.ts b/packages/vercel-messages/src/__tests__/handler.test.ts index 1bbc7ecb..f79ddecf 100644 --- a/packages/vercel-messages/src/__tests__/handler.test.ts +++ b/packages/vercel-messages/src/__tests__/handler.test.ts @@ -199,9 +199,7 @@ describe('createVercelMessagesHandler', () => { stop_sequences: ['END'], seed: 7, reasoning_effort: 'low', - max_retries: 1, tool_choice: 'auto', - provider_options: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, }; await createVercelMessagesHandler()({ ...baseConfig, model: { ...baseConfig.model, parameters } } as any, 'hello'); const request = aiMocks.generateText.mock.calls[0][0]; @@ -214,10 +212,7 @@ describe('createVercelMessagesHandler', () => { stopSequences: ['END'], seed: 7, reasoning: 'low', - maxRetries: 1, toolChoice: 'auto', - // Nested values reach the provider untouched, snake_case and all. - providerOptions: { anthropic: { thinking: { type: 'enabled', budget_tokens: 1024 } } }, }); for (const key of Object.keys(parameters).filter((k) => k.includes('_'))) { expect(request).not.toHaveProperty(key); diff --git a/packages/vercel-messages/src/__tests__/model-parameters.test.ts b/packages/vercel-messages/src/__tests__/model-parameters.test.ts new file mode 100644 index 00000000..7f7e64fd --- /dev/null +++ b/packages/vercel-messages/src/__tests__/model-parameters.test.ts @@ -0,0 +1,119 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createGateway, generateText } from 'ai'; +import { MockLanguageModelV4 } from 'ai/test'; +import { describe, expect, it } from 'vitest'; +import { createVercelMessagesHandler } from '../handler.js'; +import { buildModelParameterOptions } from '../model-parameters.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; + +// This file deliberately does NOT mock `ai`: it checks what reaches the provider's HTTP request. + +type SeenHeaders = Record[]; + +function capturingFetch(seen: SeenHeaders): typeof globalThis.fetch { + return (async (_url: unknown, init?: RequestInit) => { + seen.push(Object.fromEntries(new Headers(init?.headers).entries())); + return new Response(JSON.stringify({ error: { message: 'captured' } }), { status: 401 }); + }) as typeof globalThis.fetch; +} + +const attackerParameters = { + headers: { Authorization: 'Bearer ATTACKER' }, + extra_headers: { Authorization: 'Bearer ATTACKER' }, + provider_options: { gateway: { byok: { openai: [{ apiKey: 'ATTACKER' }] }, order: ['attacker'] } }, +}; + +function config(parameters: Record) { + return { + model: { name: 'openai/gpt-4o', parameters }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }; +} + +describe('model.parameters cannot change how the request authenticates', () => { + it('keeps the customer API key on @ai-sdk/openai when a config sets an Authorization header', async () => { + const seen: SeenHeaders = []; + const provider = createOpenAI({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + await expect( + createVercelMessagesHandler({ model: provider('gpt-4o') })(config(attackerParameters) as any, 'hi'), + ).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers.authorization).toBe('Bearer CUSTOMER'); + } + }); + + it('keeps the customer API key on the Vercel AI Gateway when a config sets an Authorization header', async () => { + const seen: SeenHeaders = []; + const gateway = createGateway({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + await expect( + createVercelMessagesHandler({ model: gateway('openai/gpt-4o') })(config(attackerParameters) as any, 'hi'), + ).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers.authorization).toBe('Bearer CUSTOMER'); + } + }); + + // Jeff's repro from the review, inverted: the options built from a config that sets + // `headers.Authorization` no longer replace the provider's own key. + it('buildModelParameterOptions output spread into generateText does not override the provider key', async () => { + const seen: SeenHeaders = []; + const provider = createOpenAI({ apiKey: 'CUSTOMER', fetch: capturingFetch(seen) }); + const opts = buildModelParameterOptions({ headers: { Authorization: 'Bearer ATTACKER' } } as never); + await generateText({ model: provider('gpt-4o'), prompt: 'hi', maxRetries: 0, ...opts }).catch(() => {}); + expect(seen[0]?.authorization).toBe('Bearer CUSTOMER'); + }); +}); + +describe('buildModelParameterOptions allowlist', () => { + it('drops every never-forwarded key', () => { + const options = buildModelParameterOptions({ ...NEVER_FORWARDED_PARAMETERS, temperature: 0.5 } as never); + expect(options).toEqual({ temperature: 0.5 }); + expectNoNeverForwardedValue(options); + }); + + it('never-forwarded keys do not reach the model call', async () => { + const model = new MockLanguageModelV4({ + doGenerate: async () => { + throw new Error('aborted: request captured'); + }, + }); + await expect( + createVercelMessagesHandler({ model })(config(NEVER_FORWARDED_PARAMETERS) as any, 'hi'), + ).rejects.toThrow(); + expect(model.doGenerateCalls).toHaveLength(1); + // The AI SDK sets its own `headers` (user agent) on every call; the deep check still proves + // none of the config's values reached it. + expectNoNeverForwardedValue(model.doGenerateCalls[0], ['headers']); + }); + + it('forwards the allowed keys under the AI SDK names', () => { + expect( + buildModelParameterOptions({ + temperature: 0.2, + top_p: 0.9, + top_k: 40, + presence_penalty: 0.1, + frequency_penalty: 0.3, + stop_sequences: ['END'], + seed: 7, + max_tokens: 100, + reasoning_effort: 'low', + tool_choice: 'auto', + } as never), + ).toEqual({ + temperature: 0.2, + topP: 0.9, + topK: 40, + presencePenalty: 0.1, + frequencyPenalty: 0.3, + stopSequences: ['END'], + seed: 7, + maxOutputTokens: 100, + reasoning: 'low', + toolChoice: 'auto', + }); + }); +}); diff --git a/packages/vercel-messages/src/__tests__/never-forwarded.ts b/packages/vercel-messages/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/vercel-messages/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/vercel-messages/src/__tests__/wire.test.ts b/packages/vercel-messages/src/__tests__/wire.test.ts index de3f413e..d5ab1cbd 100644 --- a/packages/vercel-messages/src/__tests__/wire.test.ts +++ b/packages/vercel-messages/src/__tests__/wire.test.ts @@ -12,7 +12,6 @@ const parameters = { max_tokens: 321, top_p: 0.42, stop_sequences: ['END'], - max_retries: 0, }; const baseConfig = { diff --git a/packages/vercel-messages/src/evaluate.ts b/packages/vercel-messages/src/evaluate.ts index c2c20d41..fe3e23f5 100644 --- a/packages/vercel-messages/src/evaluate.ts +++ b/packages/vercel-messages/src/evaluate.ts @@ -16,7 +16,6 @@ import { import { SpanStatusCode, trace } from '@opentelemetry/api'; import { type Experimental_EvaluationModel, type Experimental_EvaluationQuestion, experimental_evaluate } from 'ai'; import { gatewayModelId } from './model-id.js'; -import { buildEvaluateParameterOptions } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-vercel-messages'; @@ -116,8 +115,6 @@ export async function vercelEvaluate[0]>; -type VercelExcludedKeys = 'abortSignal'; +type VercelExcludedKeys = 'abortSignal' | 'headers' | 'maxRetries' | 'providerOptions' | 'timeout'; // If the AI SDK adds a call setting and it is not classified above as forwarded or excluded, // this type resolves to something other than `never` and the assignment below fails to compile, // naming the unclassified key. @@ -77,31 +79,3 @@ export function buildModelParameterOptions(parameters: AiConfigRep['model']['par if (reasoning_effort !== undefined && renamed.reasoning === undefined) renamed.reasoning = reasoning_effort; return pickForwardedModelParameters(camelizeModelParameters(renamed), FORWARDED_MODEL_PARAMETER_KEYS); } - -/** - * `experimental_evaluate` request fields `vercelEvaluate` takes from `config.model.parameters`. - * The evaluate call accepts no generation settings at all (no temperature, no token limit), so - * those are not forwarded here. - * - * Handler-owned: `model`, `state`, `questions`. Excluded: `abortSignal`, as above. - */ -const FORWARDED_EVALUATE_PARAMETER_KEYS = ['headers', 'maxRetries', 'providerOptions'] as const; - -type EvaluateHandlerOwnedKeys = 'model' | 'questions' | 'state'; -type EvaluateExcludedKeys = 'abortSignal'; -type EvaluateUndecidedModelParameterKeys = Exclude< - keyof Parameters[0], - EvaluateHandlerOwnedKeys | EvaluateExcludedKeys | (typeof FORWARDED_EVALUATE_PARAMETER_KEYS)[number] ->; -const _evaluateModelParameterKeysExhaustive: Record = {} as Record< - never, - never ->; - -/** `config.model.parameters`, camelized, narrowed to what `experimental_evaluate` accepts. */ -export function buildEvaluateParameterOptions(parameters: AiConfigRep['model']['parameters']): Record { - return pickForwardedModelParameters( - camelizeModelParameters(normalizeModelParameters(parameters)), - FORWARDED_EVALUATE_PARAMETER_KEYS, - ); -} From 37bd8a769c92dcc7edef9ed5bc520dd2f92e225c Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:50:29 -0400 Subject: [PATCH 17/23] fix(claude-agents): forward only an allowlist of query() options from model.parameters The whole camelized bag was spread into query() options, so a config could set cwd, env, permissionMode, pathToClaudeCodeExecutable, allowDangerouslySkipPermissions, mcpServers and every other host-process setting. Only maxTurns, maxThinkingTokens, thinking, effort, maxBudgetUsd, fallbackModel, outputFormat and betas are forwarded now. thinking is rebuilt as the SDK reads it, so budget_tokens arrives as budgetTokens. The native graph forwards the same allowlist per node, as the Python native graph does. --- .../src/__tests__/handler.test.ts | 83 ++++++++- .../src/__tests__/native-graph.test.ts | 40 +++++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ packages/claude-agents/src/handler.ts | 12 +- .../claude-agents/src/model-parameters.ts | 84 +++++++++ packages/claude-agents/src/native-graph.ts | 4 + 6 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 packages/claude-agents/src/__tests__/never-forwarded.ts create mode 100644 packages/claude-agents/src/model-parameters.ts diff --git a/packages/claude-agents/src/__tests__/handler.test.ts b/packages/claude-agents/src/__tests__/handler.test.ts index f0f5f357..aacd18eb 100644 --- a/packages/claude-agents/src/__tests__/handler.test.ts +++ b/packages/claude-agents/src/__tests__/handler.test.ts @@ -56,6 +56,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { import { type Message, NATIVE_TOOL_KEY, NativeTool } from '@launchdarkly/ai-server'; import { buildPrompt, buildToolMCP, createClaudeAgentsHandler, partitionTools } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -242,20 +243,86 @@ describe('createClaudeAgentsHandler', () => { expect(options.fallbackModel).toBe('claude-haiku'); }); - it('forwards unrecognized model.parameters keys through to query options rather than dropping them', async () => { - // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key - // reaching query() is expected to surface as a CLI/provider error, not be silently dropped. - // The key here has no underscore, so the snake_case->camelCase conversion this handler now - // applies leaves it alone — this test is about the allowlist, not casing. + it('drops model.parameters keys that are not on the query options allowlist', async () => { mockQuery.mockImplementation(makeResultMessage()); const config = { ...baseConfig, - model: { ...baseConfig.model, parameters: { temperature: 0.7, someUnknownKey: 100 } }, + model: { ...baseConfig.model, parameters: { temperature: 0.7, someUnknownKey: 100, maxTurns: 2 } }, }; await createClaudeAgentsHandler()(config as any, 'q'); const { options } = mockQuery.mock.calls[0][0]; - expect(options.temperature).toBe(0.7); - expect(options.someUnknownKey).toBe(100); + expect(options.temperature).toBeUndefined(); + expect(options.someUnknownKey).toBeUndefined(); + expect(options.maxTurns).toBe(2); + }); + + it('never forwards credentials, endpoints, remote tools or host-process settings to query options', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: NEVER_FORWARDED_PARAMETERS } }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + // mcpServers and hooks are set by the handler itself; the deep check proves neither carries + // the config's value. + expectNoNeverForwardedValue(options, ['mcpServers', 'hooks']); + }); + + it('never forwards them on the streaming path either', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', subtype: 'success', result: 'done', usage: {} }; + }); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: NEVER_FORWARDED_PARAMETERS } }; + await collectStream(createClaudeAgentsHandler().stream?.(config as any, 'q', {}, {})); + const { options } = mockQuery.mock.calls[0][0]; + expectNoNeverForwardedValue(options, ['mcpServers', 'hooks']); + }); + + it('forwards every allowed key under the Claude Agent SDK name and shape', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const schema = { type: 'object', properties: { answer_text: { type: 'string' } } }; + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + max_turns: 4, + max_thinking_tokens: 2048, + thinking: { type: 'enabled', budget_tokens: 1024, display: 'summarized', extra_key: 'dropped' }, + effort: 'high', + max_budget_usd: 1.5, + fallback_model: 'claude-haiku', + output_format: { type: 'json_schema', schema, extra_key: 'dropped' }, + betas: ['context-1m-2025-08-07'], + }, + }, + }; + await createClaudeAgentsHandler()(config as any, 'q'); + const { options } = mockQuery.mock.calls[0][0]; + expect(options).toMatchObject({ + maxTurns: 4, + maxThinkingTokens: 2048, + effort: 'high', + maxBudgetUsd: 1.5, + fallbackModel: 'claude-haiku', + betas: ['context-1m-2025-08-07'], + }); + // The SDK's ThinkingEnabled reads budgetTokens, not the Messages API's budget_tokens. + expect(options.thinking).toEqual({ type: 'enabled', budgetTokens: 1024, display: 'summarized' }); + // The JSON schema itself is passed through as written. + expect(options.outputFormat).toEqual({ type: 'json_schema', schema }); + }); + + it('accepts a camelCase thinking.budgetTokens and drops a thinking value without a type', async () => { + mockQuery.mockImplementation(makeResultMessage()); + const run = async (thinking: unknown) => { + mockQuery.mockClear(); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { thinking } } }; + await createClaudeAgentsHandler()(config as any, 'q'); + return mockQuery.mock.calls[0][0].options.thinking; + }; + expect(await run({ type: 'enabled', budgetTokens: 512 })).toEqual({ type: 'enabled', budgetTokens: 512 }); + expect(await run({ type: 'adaptive' })).toEqual({ type: 'adaptive' }); + expect(await run({ budget_tokens: 512 })).toBeUndefined(); + expect(await run('enabled')).toBeUndefined(); }); it('does not let model.parameters override model, tools, allowedTools, mcpServers, hooks, or systemPrompt', async () => { diff --git a/packages/claude-agents/src/__tests__/native-graph.test.ts b/packages/claude-agents/src/__tests__/native-graph.test.ts index 1faca814..71606c82 100644 --- a/packages/claude-agents/src/__tests__/native-graph.test.ts +++ b/packages/claude-agents/src/__tests__/native-graph.test.ts @@ -47,6 +47,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { import { NativeTool } from '@launchdarkly/ai-server'; import { toClaudeAgents } from '../native-graph.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -110,6 +111,45 @@ describe('toClaudeAgents', () => { } }); + it("forwards each node's allowlisted model.parameters, including maxTurns, to that node's query()", async () => { + const def = makeGraphDef(); + (def.root!.config.model as any).parameters = { + max_turns: 3, + thinking: { type: 'enabled', budget_tokens: 2048 }, + effort: 'low', + }; + (def.getNode('leaf')!.config.model as any).parameters = { max_turns: 6, max_budget_usd: 0.5 }; + await toClaudeAgents(Promise.resolve(def)).invoke('hi'); + + const rootOptions = mockQuery.mock.calls[0][0].options; + expect(rootOptions).toMatchObject({ + maxTurns: 3, + thinking: { type: 'enabled', budgetTokens: 2048 }, + effort: 'low', + model: 'claude-opus-4-5', + }); + + // The leaf runs when the root calls its sub-agent tool; call that tool's handler directly. + const leafTool = mockTool.mock.calls.find((call) => call[0] === 'leaf'); + await (leafTool![3] as (args: { input: string }) => Promise)({ input: 'sub task' }); + const leafOptions = mockQuery.mock.calls[1][0].options; + expect(leafOptions).toMatchObject({ maxTurns: 6, maxBudgetUsd: 0.5 }); + expect(leafOptions).not.toHaveProperty('max_turns'); + }); + + it('never forwards credentials, endpoints, remote tools or host-process settings from any node', async () => { + const def = makeGraphDef(); + (def.root!.config.model as any).parameters = NEVER_FORWARDED_PARAMETERS; + (def.getNode('leaf')!.config.model as any).parameters = NEVER_FORWARDED_PARAMETERS; + await toClaudeAgents(Promise.resolve(def)).invoke('hi'); + const leafTool = mockTool.mock.calls.find((call) => call[0] === 'leaf'); + await (leafTool![3] as (args: { input: string }) => Promise)({ input: 'sub task' }); + expect(mockQuery).toHaveBeenCalledTimes(2); + for (const call of mockQuery.mock.calls) { + expectNoNeverForwardedValue(call[0].options, ['mcpServers', 'hooks']); + } + }); + it('throws when the graph is disabled', async () => { const def = makeGraphDef({ enabled: false }); await expect( diff --git a/packages/claude-agents/src/__tests__/never-forwarded.ts b/packages/claude-agents/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/claude-agents/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/claude-agents/src/handler.ts b/packages/claude-agents/src/handler.ts index bdc03373..9c1f538a 100644 --- a/packages/claude-agents/src/handler.ts +++ b/packages/claude-agents/src/handler.ts @@ -5,7 +5,6 @@ import { type CanonicalTurn, type ConfigTurn, type ContentCaptureOptions, - camelizeModelParameters, composeHistory, config, contentToText, @@ -16,7 +15,6 @@ import { type MessageContent, NATIVE_TOOL_KEY, type NativeTool, - normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanMessage, @@ -36,6 +34,7 @@ import { } from '@launchdarkly/ai-server'; import { type Context, context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'; import { z } from 'zod'; +import { buildModelParameterQueryOptions } from './model-parameters.js'; const TOOL_MCP_NAME = 'tool-mcp'; const MCP_TOOL_PREFIX = `mcp__${TOOL_MCP_NAME}__`; @@ -895,9 +894,8 @@ function buildQueryOptions( // biome-ignore lint/suspicious/noExplicitAny: Claude SDK hooks config type is not publicly exported hooks: any, /** - * The customer's `model.parameters`, with top-level keys camelized for the Claude Agent - * SDK (`max_turns` becomes `maxTurns`). Untrusted: it is whatever was - * saved on the AI Config, so it is spread FIRST and every handler-owned key below + * The allowlisted subset of `model.parameters`, from `buildModelParameterQueryOptions`. + * It comes from the AI Config, so it is spread FIRST and every handler-owned key below * overrides it. Passed explicitly rather than folded into `internalOptions` so the two * trust levels cannot be confused at a call site. */ @@ -994,7 +992,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - camelizeModelParameters(normalizeModelParameters(config.model.parameters)), + buildModelParameterQueryOptions(config.model.parameters), ), )) { recordConversationId(span, message); @@ -1121,7 +1119,7 @@ export function createClaudeAgentsHandler({ captureContent = false }: ContentCap mcpAllowedTools, toolMCP, toolTelemetry?.hooks, - camelizeModelParameters(normalizeModelParameters(config.model.parameters)), + buildModelParameterQueryOptions(config.model.parameters), { includePartialMessages: true }, ), )) { diff --git a/packages/claude-agents/src/model-parameters.ts b/packages/claude-agents/src/model-parameters.ts new file mode 100644 index 00000000..3378b159 --- /dev/null +++ b/packages/claude-agents/src/model-parameters.ts @@ -0,0 +1,84 @@ +import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; + +/** + * `query()` options this package takes from `config.model.parameters`, after camelizing the + * bag (`max_turns` becomes `maxTurns`). Every other key is dropped. + * + * This is an allowlist because `model.parameters` comes from the AI Config, not from the + * application, and most `query()` options configure the host process rather than the model: + * which executable runs (`pathToClaudeCodeExecutable`, `executable`, `executableArgs`, + * `extraArgs`), its environment and working directory (`env`, `cwd`, `additionalDirectories`), + * what it may do without asking (`permissionMode`, `allowDangerouslySkipPermissions`, `sandbox`, + * `settings`, `settingSources`, `plugins`, `hooks`, `canUseTool`), which remote tools it connects + * to (`mcpServers`), and which session it resumes (`resume`, `sessionId`, `forkSession`, + * `continue`). None of those can come from a config. + */ +const FORWARDED_QUERY_OPTION_KEYS = [ + 'betas', + 'effort', + 'fallbackModel', + 'maxBudgetUsd', + 'maxThinkingTokens', + 'maxTurns', + 'outputFormat', + 'thinking', +] as const satisfies ReadonlyArray; + +/** + * `thinking` as the Claude Agent SDK reads it. The LaunchDarkly UI writes the Messages API shape, + * `{ type: 'enabled', budget_tokens: 1024 }`, but the SDK's `ThinkingEnabled` reads + * `budgetTokens`, so a snake_case budget would be ignored. Only `type`, the budget, and `display` + * are kept; anything that is not an object with a string `type` is dropped. + */ +function toThinkingOption(value: unknown): Options['thinking'] | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const source = value as Record; + if (typeof source.type !== 'string') return undefined; + const thinking: Record = { type: source.type }; + const budgetTokens = source.budget_tokens ?? source.budgetTokens; + if (typeof budgetTokens === 'number') thinking.budgetTokens = budgetTokens; + if (typeof source.display === 'string') thinking.display = source.display; + return thinking as Options['thinking']; +} + +/** + * `outputFormat` as the Claude Agent SDK reads it: `{ type: 'json_schema', schema }`, with the + * schema passed through as written. Anything else is dropped. + */ +function toOutputFormatOption(value: unknown): Options['outputFormat'] | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const source = value as Record; + if (source.type !== 'json_schema' || !source.schema || typeof source.schema !== 'object') return undefined; + return { type: 'json_schema', schema: source.schema as Record }; +} + +/** + * `config.model.parameters` as `query()` options: camelized, narrowed to + * `FORWARDED_QUERY_OPTION_KEYS`, with `thinking` and `outputFormat` rebuilt in the shapes the SDK + * reads. A config that sets none of them produces `{}`. + */ +export function buildModelParameterQueryOptions( + parameters: AiConfigRep['model']['parameters'] | unknown, +): Partial { + const options = pickForwardedModelParameters( + camelizeModelParameters(normalizeModelParameters(parameters)), + FORWARDED_QUERY_OPTION_KEYS, + ); + if (options.thinking !== undefined) { + const thinking = toThinkingOption(options.thinking); + if (thinking) options.thinking = thinking; + else delete options.thinking; + } + if (options.outputFormat !== undefined) { + const outputFormat = toOutputFormatOption(options.outputFormat); + if (outputFormat) options.outputFormat = outputFormat; + else delete options.outputFormat; + } + return options as Partial; +} diff --git a/packages/claude-agents/src/native-graph.ts b/packages/claude-agents/src/native-graph.ts index d6b7d87f..2c8fc412 100644 --- a/packages/claude-agents/src/native-graph.ts +++ b/packages/claude-agents/src/native-graph.ts @@ -15,6 +15,7 @@ import { import { SpanStatusCode, trace } from '@opentelemetry/api'; import { z } from 'zod'; import { buildPrompt, buildQueryPrompt, buildToolMCP, partitionTools } from './handler.js'; +import { buildModelParameterQueryOptions } from './model-parameters.js'; const TOOL_MCP_NAME = 'tool-mcp'; const SUBAGENT_MCP_NAME = 'subagents'; @@ -110,6 +111,9 @@ const runQuery = async ( for await (const message of query({ prompt: queryPrompt, options: { + // The node's allowlisted model.parameters (maxTurns, thinking, effort, ...), spread first so + // every handler-owned option below wins. + ...buildModelParameterQueryOptions(node.config.model.parameters), model: node.config.model.name, tools: nativeToolNames.length > 0 ? nativeToolNames : [], allowedTools: allAllowedTools.length > 0 ? allAllowedTools : undefined, From 398e935ef0cb987169a5d1113a9abbf65f20e194 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:51:28 -0400 Subject: [PATCH 18/23] fix(openai-agents): forward only an allowlist of ModelSettings fields from model.parameters The whole camelized bag became the Agent's ModelSettings, so a config could set providerData, which the Agents SDK merges into the request and reads extraHeaders, extraBody and extraQuery from. Only generation settings are forwarded now, and maxTurns goes to Runner.run alone. reasoning, text and context_management entries are rebuilt in the SDK's shapes; compact_threshold becomes compactThreshold and unknown entry keys are dropped. A top-level verbosity becomes text.verbosity. The native graph forwards each node's settings, and the root's maxTurns to Runner.run, as the Python native graph does. --- .../src/__tests__/handler.test.ts | 95 +++++++++-- .../src/__tests__/native-graph.test.ts | 33 ++++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ packages/openai-agents/src/handler.ts | 26 +-- .../openai-agents/src/model-parameters.ts | 122 +++++++++++++ packages/openai-agents/src/native-graph.ts | 13 +- 6 files changed, 410 insertions(+), 40 deletions(-) create mode 100644 packages/openai-agents/src/__tests__/never-forwarded.ts create mode 100644 packages/openai-agents/src/model-parameters.ts diff --git a/packages/openai-agents/src/__tests__/handler.test.ts b/packages/openai-agents/src/__tests__/handler.test.ts index a74c2036..abceb97d 100644 --- a/packages/openai-agents/src/__tests__/handler.test.ts +++ b/packages/openai-agents/src/__tests__/handler.test.ts @@ -101,6 +101,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { import { openaiGraph } from '../graph.js'; import { createOpenAIAgentHandler } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -249,17 +250,87 @@ describe('createOpenAIAgentHandler', () => { }); }); - it('forwards unrecognized model.parameters keys through to modelSettings rather than dropping them', async () => { - // The LaunchDarkly UI already constrains which keys can be saved, so an unsupported key - // reaching the Agents SDK is expected to surface as a provider error, not be silently dropped. + it('drops model.parameters keys that are not ModelSettings fields', async () => { mockRun.mockResolvedValue(mockRunResult()); const config = { ...baseConfig, - model: { ...baseConfig.model, parameters: { someUnknownKey: 'nope', anotherOne: 42 } }, + model: { ...baseConfig.model, parameters: { someUnknownKey: 'nope', anotherOne: 42, temperature: 0.1 } }, }; await createOpenAIAgentHandler()(config as any, 'q'); const agentArgs = mockAgentConstructor.mock.calls[0][0]; - expect(agentArgs.modelSettings).toEqual({ someUnknownKey: 'nope', anotherOne: 42 }); + expect(agentArgs.modelSettings).toEqual({ temperature: 0.1 }); + }); + + it('never forwards request headers, body or query overrides, credentials or retry policy', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { ...baseConfig, model: { ...baseConfig.model, parameters: NEVER_FORWARDED_PARAMETERS } }; + await createOpenAIAgentHandler()(config as any, 'q'); + const agentArgs = mockAgentConstructor.mock.calls[0][0]; + expect(agentArgs.modelSettings).toBeUndefined(); + expectNoNeverForwardedValue(agentArgs); + expect(mockRun.mock.calls[0][2]).toBeUndefined(); + }); + + it('forwards every allowed ModelSettings field under its SDK name and shape', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { + ...baseConfig, + model: { + ...baseConfig.model, + parameters: { + temperature: 0.4, + top_p: 0.9, + frequency_penalty: 0.1, + presence_penalty: 0.2, + tool_choice: 'auto', + parallel_tool_calls: false, + truncation: 'auto', + max_tokens: 256, + store: false, + prompt_cache_retention: '24h', + reasoning: { effort: 'low', summary: 'auto', extra_key: 'dropped' }, + verbosity: 'low', + context_management: [ + { type: 'compaction', compact_threshold: 1000, extra_key: 'dropped' }, + { type: 'compaction', compactThreshold: 2000 }, + { compact_threshold: 3000 }, + 'not-an-entry', + ], + retry: { maxRetries: 5 }, + provider_data: { extra_headers: { Authorization: 'Bearer ATTACKER' } }, + }, + }, + }; + await createOpenAIAgentHandler()(config as any, 'q'); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ + temperature: 0.4, + topP: 0.9, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + toolChoice: 'auto', + parallelToolCalls: false, + truncation: 'auto', + maxTokens: 256, + store: false, + promptCacheRetention: '24h', + reasoning: { effort: 'low', summary: 'auto' }, + text: { verbosity: 'low' }, + // Entries are rebuilt as { type, compactThreshold }; ones without a type are dropped. + contextManagement: [ + { type: 'compaction', compactThreshold: 1000 }, + { type: 'compaction', compactThreshold: 2000 }, + ], + }); + }); + + it('prefers an explicit text.verbosity over a top-level verbosity', async () => { + mockRun.mockResolvedValue(mockRunResult()); + const config = { + ...baseConfig, + model: { ...baseConfig.model, parameters: { verbosity: 'low', text: { verbosity: 'high', format: 'x' } } }, + }; + await createOpenAIAgentHandler()(config as any, 'q'); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ text: { verbosity: 'high' } }); }); it('does not set modelSettings or maxTurns when model.parameters is absent', async () => { @@ -290,7 +361,8 @@ describe('createOpenAIAgentHandler', () => { }; await createOpenAIAgentHandler()(config as any, 'q'); const agentArgs = mockAgentConstructor.mock.calls[0][0]; - expect(agentArgs.modelSettings).toEqual({ maxTurns: 4, topP: 0.9, maxTokens: 256 }); + // maxTurns is a Runner.run option, so it is not left in modelSettings. + expect(agentArgs.modelSettings).toEqual({ topP: 0.9, maxTokens: 256 }); const runOptions = mockRun.mock.calls[0][2]; expect(runOptions).toEqual({ maxTurns: 4 }); }); @@ -641,13 +713,8 @@ describe('createOpenAIAgentHandler', () => { }; const handler = createOpenAIAgentHandler(); await collectStream(handler.stream?.(config as any, 'q', {}, {})); - // `maxTurns` is read separately for Runner.run, but the whole bag (including it) also passes - // straight through to modelSettings now that there is no allowlist filtering it out. - expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ - temperature: 0.5, - maxTurns: 5, - unknownKey: 'nope', - }); + // `maxTurns` goes to Runner.run only; the unknown key is dropped. + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ temperature: 0.5 }); const runOptions = mockRun.mock.calls[0][2]; expect(runOptions).toMatchObject({ maxTurns: 5 }); }); @@ -662,7 +729,7 @@ describe('createOpenAIAgentHandler', () => { const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { max_turns: 6, top_p: 0.8 } } }; const handler = createOpenAIAgentHandler(); await collectStream(handler.stream?.(config as any, 'q', {}, {})); - expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ maxTurns: 6, topP: 0.8 }); + expect(mockAgentConstructor.mock.calls[0][0].modelSettings).toEqual({ topP: 0.8 }); const runOptions = mockRun.mock.calls[0][2]; expect(runOptions).toMatchObject({ maxTurns: 6 }); }); diff --git a/packages/openai-agents/src/__tests__/native-graph.test.ts b/packages/openai-agents/src/__tests__/native-graph.test.ts index 6d1b11bc..158653f2 100644 --- a/packages/openai-agents/src/__tests__/native-graph.test.ts +++ b/packages/openai-agents/src/__tests__/native-graph.test.ts @@ -65,6 +65,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { }); import { toOpenAIAgents } from '../native-graph.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Fixture helpers ────────────────────────────────────────────────────────── @@ -134,6 +135,38 @@ describe('toOpenAIAgents', () => { await expect(toOpenAIAgents(Promise.resolve(def as any)).invoke('hi')).rejects.toThrow(/root/i); }); + // ── model.parameters forwarding ─────────────────────────────────────────── + + it("passes each node's allowlisted model.parameters as that node's modelSettings, and the root's maxTurns to run()", async () => { + const def = makeTwoNodeGraph(); + (def.root.config.model as any).parameters = { temperature: 0.2, max_turns: 7, top_p: 0.5 }; + (def.getNode('leaf-agent')!.config.model as any).parameters = { max_tokens: 64, max_turns: 2 }; + await toOpenAIAgents(Promise.resolve(def as any)).invoke('hi'); + const byName = Object.fromEntries(mockAgentConstructor.mock.calls.map(([args]) => [args.name, args])); + expect(byName['root-agent'].modelSettings).toEqual({ temperature: 0.2, topP: 0.5 }); + expect(byName['leaf-agent'].modelSettings).toEqual({ maxTokens: 64 }); + // One run drives the whole graph, so only the root's turn cap applies, as in the Python SDK. + expect(mockRunnerRun.mock.calls[0][2]).toEqual({ maxTurns: 7 }); + }); + + it('sets no modelSettings and no run options when no node has model.parameters', async () => { + await toOpenAIAgents(Promise.resolve(makeTwoNodeGraph())).invoke('hi'); + for (const [args] of mockAgentConstructor.mock.calls) { + expect(args).not.toHaveProperty('modelSettings'); + } + expect(mockRunnerRun.mock.calls[0][2]).toBeUndefined(); + }); + + it('never forwards request overrides, credentials or retry policy from any node', async () => { + const def = makeTwoNodeGraph(); + (def.root.config.model as any).parameters = NEVER_FORWARDED_PARAMETERS; + (def.getNode('leaf-agent')!.config.model as any).parameters = NEVER_FORWARDED_PARAMETERS; + await toOpenAIAgents(Promise.resolve(def as any)).invoke('hi'); + for (const [args] of mockAgentConstructor.mock.calls) { + expectNoNeverForwardedValue(args); + } + }); + // ── Topology translation ────────────────────────────────────────────────── it('creates one Agent per node in a two-node graph', async () => { diff --git a/packages/openai-agents/src/__tests__/never-forwarded.ts b/packages/openai-agents/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/openai-agents/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/openai-agents/src/handler.ts b/packages/openai-agents/src/handler.ts index f995458f..c1e9934b 100644 --- a/packages/openai-agents/src/handler.ts +++ b/packages/openai-agents/src/handler.ts @@ -3,7 +3,6 @@ import { type CanonicalTurn, type ConfigTurn, type ContentCaptureOptions, - camelizeModelParameters, composeHistory, config, contentToText, @@ -14,7 +13,6 @@ import { type Message, type MessageContent, type NativeTool, - normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanMessage, @@ -35,13 +33,13 @@ import type { ModelProvider, ModelRequest, ModelResponse, - ModelSettings, RunRawModelStreamEvent, StreamEvent, StreamedRunResult, } from '@openai/agents'; import { Agent, Runner, tool } from '@openai/agents'; import { type Context, context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'; +import { buildMaxTurns, buildModelSettings } from './model-parameters.js'; import { buildOutputType } from './utils.js'; const TRACER_NAME = '@launchdarkly/ai-openai-agents'; @@ -498,28 +496,6 @@ function configConversationTurns(config: AiConfigRep, variables: Record ({ role: m.role as 'user' | 'assistant', content: parseTemplate(m.content, variables) })); } -/** - * `config.model.parameters`, top-level keys camelized (`top_p` becomes `topP`) and passed as the - * Agents SDK's `ModelSettings`, unchanged - * otherwise: no default temperature, no default cap — a config that sets nothing here produces - * `undefined`, so the Agent is constructed exactly as it always has been. - */ -function buildModelSettings(parameters: AiConfigRep['model']['parameters']): ModelSettings | undefined { - const settings = camelizeModelParameters(normalizeModelParameters(parameters)); - return Object.keys(settings).length > 0 ? (settings as ModelSettings) : undefined; -} - -/** - * `maxTurns` is a `Runner.run` option, not a `ModelSettings` field — it caps the agentic loop - * rather than tuning any single model call — so it is read out of `model.parameters` separately - * and forwarded to `run()` instead of the `Agent` constructor. The bag is camelized first so a - * config that saves `max_turns` (the UI's convention) is read here too, not just `maxTurns`. - */ -function buildMaxTurns(parameters: AiConfigRep['model']['parameters']): number | undefined { - const maxTurns = camelizeModelParameters(normalizeModelParameters(parameters)).maxTurns; - return typeof maxTurns === 'number' ? maxTurns : undefined; -} - function buildAgentAndPrompt( config: AiConfigRep, userInput: string, diff --git a/packages/openai-agents/src/model-parameters.ts b/packages/openai-agents/src/model-parameters.ts new file mode 100644 index 00000000..54422a31 --- /dev/null +++ b/packages/openai-agents/src/model-parameters.ts @@ -0,0 +1,122 @@ +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; +import type { ModelSettings } from '@openai/agents'; + +/** + * `ModelSettings` fields this package takes from `config.model.parameters`, after camelizing the + * bag (`top_p` becomes `topP`). Every other key is dropped. + */ +const FORWARDED_MODEL_SETTINGS_KEYS = [ + 'contextManagement', + 'frequencyPenalty', + 'maxTokens', + 'parallelToolCalls', + 'presencePenalty', + 'promptCacheRetention', + 'reasoning', + 'store', + 'temperature', + 'text', + 'toolChoice', + 'topP', + 'truncation', +] as const satisfies ReadonlyArray; + +/** + * `ModelSettings` fields that are never taken from a config: + * + * - `providerData` is merged into the Responses API request as written, and carries the Agents + * SDK's transport overrides (`extraHeaders`, `extraBody`, `extraQuery`), so a config could + * replace the request's headers (including authorization) or body. + * - `retry` is client retry policy, not a model setting. + */ +type ExcludedModelSettingsKeys = 'providerData' | 'retry'; +// If the Agents SDK adds a ModelSettings field and it is not classified above as forwarded or +// excluded, this type resolves to something other than `never` and the assignment below fails to +// compile, naming the unclassified key. +type UndecidedModelSettingsKeys = Exclude< + keyof ModelSettings, + ExcludedModelSettingsKeys | (typeof FORWARDED_MODEL_SETTINGS_KEYS)[number] +>; +const _modelSettingsKeysExhaustive: Record = {} as Record; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** Keeps only the named keys of a nested object, or returns `undefined` if none are set. */ +function pickNested(value: unknown, keys: ReadonlyArray): Record | undefined { + if (!isPlainObject(value)) return undefined; + const picked: Record = {}; + for (const key of keys) { + if (value[key] !== undefined) picked[key] = value[key]; + } + return Object.keys(picked).length > 0 ? picked : undefined; +} + +/** + * `context_management` as `ModelSettings.contextManagement`: each entry is rebuilt as + * `{ type, compactThreshold }`, accepting `compact_threshold` (the Responses API spelling the UI + * writes) or `compactThreshold`. The Agents SDK snake-cases every key of an entry into the + * request, so any other key is dropped rather than passed along. Entries without a string `type` + * are dropped. + */ +function toContextManagement(value: unknown): ModelSettings['contextManagement'] | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.flatMap((entry) => { + if (!isPlainObject(entry) || typeof entry.type !== 'string') return []; + const threshold = entry.compact_threshold ?? entry.compactThreshold; + return [{ type: entry.type, ...(typeof threshold === 'number' ? { compactThreshold: threshold } : {}) }]; + }); + return entries.length > 0 ? entries : undefined; +} + +/** + * `config.model.parameters` as the Agents SDK's `ModelSettings`: camelized, narrowed to + * `FORWARDED_MODEL_SETTINGS_KEYS`, with nested values rebuilt in the shapes the SDK reads: + * + * - `reasoning` keeps `effort` and `summary`. + * - `text` keeps `verbosity`. A top-level `verbosity` (the name the UI and the Python SDK use) + * becomes `text.verbosity`; an explicit `text.verbosity` wins. + * - `context_management` entries are rebuilt by `toContextManagement`. + * + * `max_turns` is not a `ModelSettings` field; see `buildMaxTurns`. A config that sets none of + * these produces `undefined`, so the Agent is constructed exactly as it always has been. + */ +export function buildModelSettings( + parameters: AiConfigRep['model']['parameters'] | unknown, +): ModelSettings | undefined { + const camelized = camelizeModelParameters(normalizeModelParameters(parameters)); + const settings = pickForwardedModelParameters(camelized, FORWARDED_MODEL_SETTINGS_KEYS); + + const reasoning = pickNested(settings.reasoning, ['effort', 'summary']); + if (reasoning) settings.reasoning = reasoning; + else delete settings.reasoning; + + const text = pickNested({ verbosity: camelized.verbosity, ...pickNested(settings.text, ['verbosity']) }, [ + 'verbosity', + ]); + if (text) settings.text = text; + else delete settings.text; + + const contextManagement = toContextManagement(settings.contextManagement); + if (contextManagement) settings.contextManagement = contextManagement; + else delete settings.contextManagement; + + return Object.keys(settings).length > 0 ? (settings as ModelSettings) : undefined; +} + +/** + * `maxTurns` is a `Runner.run` option, not a `ModelSettings` field: it caps the agentic loop + * rather than tuning any single model call, so it is read out of `model.parameters` separately + * and forwarded to `run()` instead of the `Agent` constructor. Both `max_turns` (the UI's + * spelling) and `maxTurns` are read. + */ +export function buildMaxTurns(parameters: AiConfigRep['model']['parameters'] | unknown): number | undefined { + const maxTurns = camelizeModelParameters(normalizeModelParameters(parameters)).maxTurns; + return typeof maxTurns === 'number' ? maxTurns : undefined; +} diff --git a/packages/openai-agents/src/native-graph.ts b/packages/openai-agents/src/native-graph.ts index 48d251b3..d100e7e2 100644 --- a/packages/openai-agents/src/native-graph.ts +++ b/packages/openai-agents/src/native-graph.ts @@ -16,6 +16,7 @@ import { } from '@launchdarkly/ai-server'; import { Agent, handoff, Runner, tool } from '@openai/agents'; import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { buildMaxTurns, buildModelSettings } from './model-parameters.js'; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -150,9 +151,11 @@ export const toOpenAIAgents = ( const agentName = sanitizeName(node.key); agentNameToKey.set(agentName, node.key); + const modelSettings = buildModelSettings(node.config.model.parameters); const agent = new Agent({ name: agentName, model: node.config.model.name, + ...(modelSettings ? { modelSettings } : {}), ...(instructions ? { instructions } : {}), ...(tools.length > 0 ? { tools } : {}), ...(childHandoffs.length > 0 ? { handoffs: childHandoffs } : {}), @@ -214,11 +217,19 @@ export const toOpenAIAgents = ( // handoffs and receive their context from the Runner, not from `history`. const rootInput = history && history.length > 0 ? toRunnerInput(history, input) : input; + // One Runner.run drives the whole graph through handoffs, so the turn cap is the root + // node's `max_turns`, as in the Python SDK. + const maxTurns = buildMaxTurns(root.config.model.parameters); + // biome-ignore lint/suspicious/noImplicitAnyLet: assigned immediately in try; catch always re-throws let result; try { // biome-ignore lint/suspicious/noExplicitAny: Runner.run accepts string | AgentInputItem[]; our item shape is structurally compatible - result = await runner.run(rootAgent, rootInput as any); + const runInput = rootInput as any; + result = + maxTurns !== undefined + ? await runner.run(rootAgent, runInput, { maxTurns }) + : await runner.run(rootAgent, runInput); span.setStatus({ code: SpanStatusCode.OK }); } catch (err) { span.recordException(err instanceof Error ? err : new Error(String(err))); From 0990a51e54828c71639d59d5bc44d8653bbf81d7 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:53:08 -0400 Subject: [PATCH 19/23] fix(langchain): forward only a per-model-class allowlist of constructor fields Camelizing the whole bag turned api_key, anthropic_api_url, client_options, configuration, model_kwargs and Bedrock credentials, region and endpoint_host into working ChatOpenAI, ChatAnthropic and ChatBedrockConverse constructor options. Each class now takes only its generation settings, mirroring the Python SDK's lists without model_kwargs, mcp_servers or region_name. ChatAnthropic gets a top-level effort as outputConfig.effort, and ChatOpenAI its service_tier spelling. The LangGraph native graph's default ChatOpenAI uses the same list. --- .../src/__tests__/handler.test.ts | 31 +++- .../src/__tests__/model-parameters.test.ts | 135 +++++++++++++++ .../src/__tests__/native-graph.test.ts | 15 +- .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ .../src/__tests__/wire.test.ts | 92 ++++++---- packages/langchain-agents/src/handler.ts | 29 ++-- .../langchain-agents/src/model-parameters.ts | 146 ++++++++++++++++ packages/langchain-agents/src/native-graph.ts | 5 +- .../src/__tests__/handler.test.ts | 30 +++- .../src/__tests__/model-parameters.test.ts | 135 +++++++++++++++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ .../src/__tests__/wire.test.ts | 90 ++++++---- packages/langchain-messages/src/handler.ts | 27 +-- .../src/model-parameters.ts | 146 ++++++++++++++++ 14 files changed, 1102 insertions(+), 101 deletions(-) create mode 100644 packages/langchain-agents/src/__tests__/model-parameters.test.ts create mode 100644 packages/langchain-agents/src/__tests__/never-forwarded.ts create mode 100644 packages/langchain-agents/src/model-parameters.ts create mode 100644 packages/langchain-messages/src/__tests__/model-parameters.test.ts create mode 100644 packages/langchain-messages/src/__tests__/never-forwarded.ts create mode 100644 packages/langchain-messages/src/model-parameters.ts diff --git a/packages/langchain-agents/src/__tests__/handler.test.ts b/packages/langchain-agents/src/__tests__/handler.test.ts index 54bfd0ca..472f247b 100644 --- a/packages/langchain-agents/src/__tests__/handler.test.ts +++ b/packages/langchain-agents/src/__tests__/handler.test.ts @@ -84,6 +84,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { }); import { createLangChainAgentsHandler } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -1003,11 +1004,10 @@ describe('model source', () => { expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: llm })); }); - // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so - // `modelConstructorArgs` camelizes the snake_case bag the UI writes before spreading it in - // here — this is the exact conversion the casing fix adds, so this assertion changed from - // `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. - it('spreads model.parameters into the default OpenAI constructor, camelized', async () => { + // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so the + // snake_case bag the UI writes is camelized, then narrowed to ChatOpenAI's allowlist: `tools` + // is not a constructor field, so it is dropped. + it('passes allowlisted model.parameters to the default OpenAI constructor, camelized, and drops the rest', async () => { const constructed = { tag: 'default-openai' }; MockChatOpenAI.mockImplementation(function MockChatOpenAI() { return constructed; @@ -1020,7 +1020,6 @@ describe('model source', () => { expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, - tools: ['openai-tool'], model: 'gpt-4o', }); expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: constructed })); @@ -1040,6 +1039,26 @@ describe('model source', () => { expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); }); + it.each([ + ['OpenAI', 'gpt-4o', MockChatOpenAI], + ['Anthropic', 'claude-sonnet-4-5', MockChatAnthropic], + ['Bedrock', 'anthropic.claude-sonnet-4-5', MockChatBedrockConverse], + ] as const)('never passes credentials or connection settings to the default %s constructor', async (provider, name, ctor) => { + ctor.mockClear(); + ctor.mockImplementation(function MockChatModel() { + return {}; + }); + const cfg = { + provider: { name: provider }, + model: { name, parameters: { ...NEVER_FORWARDED_PARAMETERS, temperature: 0.3 } }, + instructions: 'Be helpful.', + }; + await createLangChainAgentsHandler()(cfg as any, 'q'); + const args = ctor.mock.calls.at(-1)![0] as Record; + expect(args.temperature).toBe(0.3); + expectNoNeverForwardedValue(args); + }); + it('spreads model.parameters into the default Anthropic constructor', async () => { const constructed = { tag: 'default-anthropic' }; MockChatAnthropic.mockImplementation(function MockChatAnthropic() { diff --git a/packages/langchain-agents/src/__tests__/model-parameters.test.ts b/packages/langchain-agents/src/__tests__/model-parameters.test.ts new file mode 100644 index 00000000..3b7b2f9e --- /dev/null +++ b/packages/langchain-agents/src/__tests__/model-parameters.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { modelConstructorParameters } from '../model-parameters.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; + +describe('modelConstructorParameters', () => { + it.each(['openai', 'anthropic', 'bedrock'] as const)('drops every never-forwarded key for %s', (modelClass) => { + const args = modelConstructorParameters({ ...NEVER_FORWARDED_PARAMETERS, temperature: 0.5 }, modelClass); + expect(args).toEqual({ temperature: 0.5 }); + expectNoNeverForwardedValue(args); + }); + + it.each(['openai', 'anthropic', 'bedrock'] as const)('drops unknown keys and model for %s', (modelClass) => { + expect( + modelConstructorParameters( + { model: 'smuggled', model_name: 'smuggled', tools: ['x'], unknown_key: 1, callbacks: [] }, + modelClass, + ), + ).toEqual({}); + }); + + it('forwards ChatOpenAI fields under their constructor names', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + max_completion_tokens: 200, + top_p: 0.9, + frequency_penalty: 0.1, + presence_penalty: 0.2, + n: 2, + logit_bias: { '50256': -100 }, + logprobs: true, + top_logprobs: 3, + stop: ['END'], + stream_usage: true, + reasoning: { effort: 'low' }, + verbosity: 'low', + service_tier: 'flex', + prompt_cache_key: 'k', + use_responses_api: true, + user: 'u-1', + tags: ['t'], + metadata: { a: 1 }, + }, + 'openai', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + maxCompletionTokens: 200, + topP: 0.9, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + n: 2, + logitBias: { '50256': -100 }, + logprobs: true, + topLogprobs: 3, + stop: ['END'], + streamUsage: true, + reasoning: { effort: 'low' }, + verbosity: 'low', + // ChatOpenAI reads this one field in snake_case. + service_tier: 'flex', + promptCacheKey: 'k', + useResponsesApi: true, + user: 'u-1', + tags: ['t'], + metadata: { a: 1 }, + }); + }); + + it('forwards ChatAnthropic fields, keeps nested Messages API shapes, and folds effort into outputConfig', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + top_k: 40, + top_p: 0.9, + stop_sequences: ['END'], + thinking: { type: 'enabled', budget_tokens: 1024 }, + context_management: { edits: [{ type: 'clear_tool_uses_20250919' }] }, + inference_geo: 'us', + betas: ['context-1m-2025-08-07'], + effort: 'low', + }, + 'anthropic', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + topK: 40, + topP: 0.9, + stopSequences: ['END'], + // ChatAnthropic sends these to the Messages API as written, so they stay snake_case. + thinking: { type: 'enabled', budget_tokens: 1024 }, + contextManagement: { edits: [{ type: 'clear_tool_uses_20250919' }] }, + inferenceGeo: 'us', + betas: ['context-1m-2025-08-07'], + outputConfig: { effort: 'low' }, + }); + expect(modelConstructorParameters({ effort: 'low', output_config: { effort: 'high' } }, 'anthropic')).toEqual({ + outputConfig: { effort: 'high' }, + }); + }); + + it('forwards ChatBedrockConverse fields under their constructor names', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + top_p: 0.9, + additional_model_request_fields: { top_k: 40 }, + guardrail_config: { guardrailIdentifier: 'g', guardrailVersion: '1' }, + performance_config: { latency: 'optimized' }, + service_tier: 'priority', + supports_tool_choice_values: ['auto'], + application_inference_profile: 'arn:smuggled', + }, + 'bedrock', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + topP: 0.9, + additionalModelRequestFields: { top_k: 40 }, + guardrailConfig: { guardrailIdentifier: 'g', guardrailVersion: '1' }, + performanceConfig: { latency: 'optimized' }, + serviceTier: 'priority', + supportsToolChoiceValues: ['auto'], + }); + }); +}); diff --git a/packages/langchain-agents/src/__tests__/native-graph.test.ts b/packages/langchain-agents/src/__tests__/native-graph.test.ts index eb6fe310..48d9d6d7 100644 --- a/packages/langchain-agents/src/__tests__/native-graph.test.ts +++ b/packages/langchain-agents/src/__tests__/native-graph.test.ts @@ -98,6 +98,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { }); import { toLangGraph } from '../native-graph.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -179,8 +180,7 @@ describe('toLangGraph', () => { }); // ChatOpenAI only reads camelCase, so the graph's default model factory camelizes the - // snake_case bag the UI writes — the exact conversion the casing fix adds, so this assertion - // changed from `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. + // snake_case bag the UI writes, then narrows it to ChatOpenAI's allowlist. it('spreads model.parameters into the default ChatOpenAI constructor, camelized', async () => { const root = makeNode('root', '', []); root.config.model.parameters = { temperature: 0.2, max_tokens: 512 }; @@ -199,6 +199,17 @@ describe('toLangGraph', () => { expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); }); + it('never passes credentials or connection settings to the default ChatOpenAI constructor', async () => { + const root = makeNode('root', '', []); + root.config.model.parameters = { ...NEVER_FORWARDED_PARAMETERS, temperature: 0.3 }; + const def = makeGraphDef([root], {}, 'root'); + MockChatOpenAI.mockClear(); + await toLangGraph(Promise.resolve(def)).invoke('hi'); + const args = MockChatOpenAI.mock.calls.at(-1)![0] as Record; + expect(args).toEqual({ temperature: 0.3, model: 'gpt-4o' }); + expectNoNeverForwardedValue(args); + }); + it('wires the root node from START', async () => { const root = makeNode('root', '', ['leaf']); const leaf = makeNode('leaf', '', []); diff --git a/packages/langchain-agents/src/__tests__/never-forwarded.ts b/packages/langchain-agents/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/langchain-agents/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/langchain-agents/src/__tests__/wire.test.ts b/packages/langchain-agents/src/__tests__/wire.test.ts index 95868c12..f5f80d52 100644 --- a/packages/langchain-agents/src/__tests__/wire.test.ts +++ b/packages/langchain-agents/src/__tests__/wire.test.ts @@ -1,53 +1,83 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLangChainAgentsHandler } from '../handler.js'; +import { NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; -// This file deliberately does NOT mock @langchain/openai or the real `langchain` createAgent. -// Every other test in this package mocks both out, so a handler-internal bug that maps a config -// key to the wrong ChatOpenAI constructor field would never reach a real request body — which is -// exactly how the snake_case bug this package's casing fix addresses went unnoticed. Constructing -// a real ChatOpenAI (via the real agent executor) and intercepting its outgoing fetch call is the -// only check that would have caught it. +// This file deliberately does NOT mock @langchain/openai. Every other test in this package mocks +// it out, so a handler-internal bug that maps a config key to the wrong ChatOpenAI constructor +// field would never reach a real request body. Constructing a real ChatOpenAI and intercepting +// its outgoing fetch call checks what actually goes on the wire. + +type Captured = { url: string; headers: Record; body: Record }; + +const captured: Captured[] = []; + +beforeEach(() => { + captured.length = 0; + // The customer's own credentials, from the environment, as an application would set them. + vi.stubEnv('OPENAI_API_KEY', 'CUSTOMER'); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown, init?: RequestInit) => { + captured.push({ + url: String(url instanceof Request ? url.url : url), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body: JSON.parse(String(init?.body)), + }); + // A 401 is not retried by either the openai client or LangChain's AsyncCaller. + return new Response(JSON.stringify({ error: { message: 'captured' } }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); const baseConfig = { - model: { - name: 'gpt-4o', - parameters: { - top_p: 0.42, - max_tokens: 321, - }, - }, + model: { name: 'gpt-4o', parameters: { top_p: 0.42, max_tokens: 321 } }, provider: { name: 'LangChain' }, instructions: 'You are helpful.', }; -describe('createLangChainAgentsHandler — wire request (real ChatOpenAI + createAgent, unmocked)', () => { +describe('createLangChainAgentsHandler wire request (real ChatOpenAI, unmocked)', () => { it('sends snake_case model.parameters onto the wire as top_p / max_tokens', async () => { - let capturedBody: Record | undefined; - - const fetchFn = async (_url: unknown, init?: { body?: unknown }) => { - capturedBody = JSON.parse(String(init?.body)); - // Abort before any real network call is attempted. - throw new Error('aborted: request captured'); - }; + await expect(createLangChainAgentsHandler()(baseConfig as any, 'hi')).rejects.toThrow(); + expect(captured.length).toBeGreaterThan(0); + expect(captured[0].body.top_p).toBe(0.42); + expect(captured[0].body.max_tokens).toBe(321); + }, 10000); + it('keeps the customer key, base URL and headers when a config sets credentials, endpoints and headers', async () => { const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { ...baseConfig.model.parameters, - // LangChain's own AsyncCaller retries a thrown call up to 6 times by default; without - // this the thrown fetch above is retried repeatedly and the test times out. - maxRetries: 0, - configuration: { apiKey: 'test-key', fetch: fetchFn }, + ...NEVER_FORWARDED_PARAMETERS, + api_key: 'ATTACKER', + base_url: 'https://attacker.example/v1', + configuration: { + apiKey: 'ATTACKER', + baseURL: 'https://attacker.example/v1', + defaultHeaders: { Authorization: 'Bearer ATTACKER' }, + }, + model_kwargs: { injected: 'NEVER_FORWARDED' }, }, }, }; - await expect(createLangChainAgentsHandler()(config as any, 'hi')).rejects.toThrow(); - - expect(capturedBody).toBeDefined(); - expect(capturedBody!.top_p).toBe(0.42); - expect(capturedBody!.max_tokens).toBe(321); + expect(captured.length).toBeGreaterThan(0); + for (const request of captured) { + expect(request.url.startsWith('https://api.openai.com/')).toBe(true); + expect(request.headers.authorization).toBe('Bearer CUSTOMER'); + expect(JSON.stringify(request)).not.toContain('NEVER_FORWARDED'); + expect(JSON.stringify(request)).not.toContain('ATTACKER'); + } + expect(captured[0].body.top_p).toBe(0.42); }, 10000); }); diff --git a/packages/langchain-agents/src/handler.ts b/packages/langchain-agents/src/handler.ts index 18481d97..7953a87b 100644 --- a/packages/langchain-agents/src/handler.ts +++ b/packages/langchain-agents/src/handler.ts @@ -6,7 +6,6 @@ import { type AiConfigRep, type ConfigTurn, type ContentCaptureOptions, - camelizeModelParameters, composeHistory, config, createHandler, @@ -18,7 +17,6 @@ import { langChainSpanMessages, langChainSpanUsage, type Message, - normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanUsage, @@ -35,6 +33,7 @@ import { import { type Context, context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'; import { createAgent } from 'langchain'; import { toLangChainMessages } from './messages.js'; +import { type LangChainModelClass, modelConstructorParameters } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-langchain-agents'; @@ -261,10 +260,16 @@ export function buildSpanCallbacks( export type LangChainModelSource = BaseChatModel | ((config: AiConfigRep) => BaseChatModel | Promise); -function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { ...camelizeModelParameters(normalizeModelParameters(config.model?.parameters)) }; - if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; - return { ...parameters, model: resolvedModelName(config, fallbackName) }; +function modelConstructorArgs( + config: AiConfigRep, + fallbackName: string, + modelClass: LangChainModelClass, +): Record { + // Name from the config always wins; `model` is never on a forwarded-keys list anyway. + return { + ...modelConstructorParameters(config.model?.parameters, modelClass), + model: resolvedModelName(config, fallbackName), + }; } async function resolveBaseModel(aiConfig: AiConfigRep, llm?: LangChainModelSource): Promise { @@ -274,8 +279,8 @@ async function resolveBaseModel(aiConfig: AiConfigRep, llm?: LangChainModelSourc const provider = (invocation.provider?.name ?? '').toLowerCase(); if (provider === 'anthropic') { const { ChatAnthropic } = await import('@langchain/anthropic'); - // biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped - return new ChatAnthropic(modelConstructorArgs(invocation, 'claude-3-5-sonnet-20241022') as any); + // biome-ignore lint/suspicious/noExplicitAny: constructor fields are narrowed per model class in modelConstructorParameters + return new ChatAnthropic(modelConstructorArgs(invocation, 'claude-3-5-sonnet-20241022', 'anthropic') as any); } if (provider === 'bedrock') { // biome-ignore lint/suspicious/noExplicitAny: @langchain/aws loaded via dynamic import with no static types @@ -285,11 +290,11 @@ async function resolveBaseModel(aiConfig: AiConfigRep, llm?: LangChainModelSourc } catch { throw new Error('Using Bedrock models requires @langchain/aws. Install it with: npm install @langchain/aws'); } - // biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped - return new mod.ChatBedrockConverse(modelConstructorArgs(invocation, '') as any); + // biome-ignore lint/suspicious/noExplicitAny: constructor fields are narrowed per model class in modelConstructorParameters + return new mod.ChatBedrockConverse(modelConstructorArgs(invocation, '', 'bedrock') as any); } - // biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped - return new ChatOpenAI(modelConstructorArgs(invocation, 'gpt-4o') as any); + // biome-ignore lint/suspicious/noExplicitAny: constructor fields are narrowed per model class in modelConstructorParameters + return new ChatOpenAI(modelConstructorArgs(invocation, 'gpt-4o', 'openai') as any); } const buildAgentTools = (configTools: Record, toolHandlers: Record) => diff --git a/packages/langchain-agents/src/model-parameters.ts b/packages/langchain-agents/src/model-parameters.ts new file mode 100644 index 00000000..65fea6a9 --- /dev/null +++ b/packages/langchain-agents/src/model-parameters.ts @@ -0,0 +1,146 @@ +import type { ChatAnthropicInput } from '@langchain/anthropic'; +import type { ChatBedrockConverseInput } from '@langchain/aws'; +import type { ChatOpenAIFields } from '@langchain/openai'; +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; + +/** + * Constructor fields every LangChain chat model takes from `BaseChatModelParams` that a config + * can set as JSON. `callbacks`, `callbackManager`, `cache` and `maxConcurrency` are wiring, and + * `maxRetries` is client retry policy, so none of them are forwarded. + */ +const BASE_CHAT_MODEL_KEYS = ['disableStreaming', 'metadata', 'outputVersion', 'tags', 'verbose'] as const; + +/** + * `ChatOpenAI` constructor fields this package takes from `config.model.parameters`, after + * camelizing the bag. Mirrors the Python SDK's `ChatOpenAI` list. + * + * Never forwarded: `apiKey`, `openAIApiKey`, `configuration` (client options, including the base + * URL, default headers, fetch and proxy settings), `timeout`, `maxRetries`, `modelKwargs` (merged + * into the request body as written), `prefixMessages` (prompt content), `model` / `modelName` + * (the handler sets the model), and the client internals `completions`, `responses`, + * `__includeRawResponse` and `supportsStrictToolCalling`. + */ +export const CHAT_OPENAI_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'audio', + 'frequencyPenalty', + 'logitBias', + 'logprobs', + 'maxCompletionTokens', + 'maxTokens', + 'modalities', + 'n', + 'presencePenalty', + 'promptCacheKey', + 'promptCacheRetention', + 'reasoning', + 'service_tier', + 'stop', + 'stopSequences', + 'streamUsage', + 'streaming', + 'temperature', + 'topLogprobs', + 'topP', + 'useResponsesApi', + 'user', + 'verbosity', + 'zdrEnabled', +] as const satisfies ReadonlyArray; + +/** + * `ChatAnthropic` constructor fields this package takes from `config.model.parameters`, after + * camelizing the bag. Mirrors the Python SDK's `ChatAnthropic` list, minus `mcp_servers` (remote + * tools) and `model_kwargs` (request injection), neither of which a config may set. + * + * Never forwarded: `apiKey`, `anthropicApiKey`, `anthropicApiUrl`, `clientOptions` (including + * default headers and fetch), `createClient`, `invocationKwargs` (merged into the request body as + * written), and `model` / `modelName`. + * + * Nested values (`thinking`, `contextManagement`, `outputConfig`) are the Anthropic Messages API's + * own snake_case shapes, which `ChatAnthropic` sends as written, so they are not converted. A + * top-level `effort` (the name the UI writes) becomes `outputConfig.effort`; see + * `modelConstructorParameters`. + */ +export const CHAT_ANTHROPIC_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'betas', + 'contextManagement', + 'inferenceGeo', + 'maxTokens', + 'outputConfig', + 'stopSequences', + 'streamUsage', + 'streaming', + 'temperature', + 'thinking', + 'topK', + 'topP', +] as const satisfies ReadonlyArray; + +/** + * `ChatBedrockConverse` constructor fields this package takes from `config.model.parameters`, + * after camelizing the bag. Mirrors the Python SDK's `ChatBedrockConverse` list, minus + * `region_name`, which is connection configuration. + * + * Never forwarded: `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, + * `bedrockApiSessionToken`, the AWS credential-provider settings (`profile`, `roleArn`, ...), + * `endpointHost`, `client`, `clientOptions`, `defaultHeaders`, `model`, and + * `applicationInferenceProfile` (which replaces the model the handler resolved). + */ +export const CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'additionalModelRequestFields', + 'guardrailConfig', + 'maxTokens', + 'performanceConfig', + 'serviceTier', + 'streamUsage', + 'streaming', + 'supportsToolChoiceValues', + 'temperature', + 'topP', +] as const satisfies ReadonlyArray; + +export type LangChainModelClass = 'openai' | 'anthropic' | 'bedrock'; + +const FORWARDED_KEYS: Record> = { + openai: CHAT_OPENAI_FORWARDED_KEYS, + anthropic: CHAT_ANTHROPIC_FORWARDED_KEYS, + bedrock: CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, +}; + +/** + * `config.model.parameters` as constructor fields for one LangChain chat model class: camelized + * (`top_p` becomes `topP`; nested values are left alone, and snake_case wins when both spellings + * are set), then narrowed to that class's allowlist. Unknown keys are dropped. + * + * Renames before the allowlist is applied: + * - `ChatOpenAI` reads its service tier as `service_tier`, so a camelized `serviceTier` is + * renamed back. + * - `ChatAnthropic` has no `effort` field; a top-level `effort` becomes `outputConfig.effort`. + * An explicit `output_config.effort` wins. + */ +export function modelConstructorParameters( + parameters: AiConfigRep['model']['parameters'] | unknown, + modelClass: LangChainModelClass, +): Record { + const camelized = camelizeModelParameters(normalizeModelParameters(parameters)); + if (modelClass === 'openai' && camelized.serviceTier !== undefined) { + camelized.service_tier = camelized.serviceTier; + delete camelized.serviceTier; + } + if (modelClass === 'anthropic' && camelized.effort !== undefined) { + const outputConfig = + camelized.outputConfig && typeof camelized.outputConfig === 'object' && !Array.isArray(camelized.outputConfig) + ? (camelized.outputConfig as Record) + : {}; + camelized.outputConfig = { effort: camelized.effort, ...outputConfig }; + } + return pickForwardedModelParameters(camelized, FORWARDED_KEYS[modelClass]); +} diff --git a/packages/langchain-agents/src/native-graph.ts b/packages/langchain-agents/src/native-graph.ts index e56f6ed5..e77818f8 100644 --- a/packages/langchain-agents/src/native-graph.ts +++ b/packages/langchain-agents/src/native-graph.ts @@ -6,7 +6,6 @@ import { ToolNode, toolsCondition } from '@langchain/langgraph/prebuilt'; import { ChatOpenAI } from '@langchain/openai'; import type { LDContext } from '@launchdarkly/ai-server'; import { - camelizeModelParameters, composeHistory, type GraphDefinition, type GraphNode, @@ -14,7 +13,6 @@ import { type Message, makeNodeTrackData, type NativeTool, - normalizeModelParameters, type ProviderGraphResponse, parseTemplate, type ToolHandlerFn, @@ -22,6 +20,7 @@ import { import { SpanStatusCode, trace } from '@opentelemetry/api'; import { z } from 'zod'; import { toLangChainMessages } from './messages.js'; +import { modelConstructorParameters } from './model-parameters.js'; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -146,7 +145,7 @@ export const toLangGraph = ( opts?.modelFactory ?? ((node) => new ChatOpenAI({ - ...camelizeModelParameters(normalizeModelParameters(node.config.model.parameters)), + ...modelConstructorParameters(node.config.model.parameters, 'openai'), model: node.config.model.name, })); const ldContext = opts?.context; diff --git a/packages/langchain-messages/src/__tests__/handler.test.ts b/packages/langchain-messages/src/__tests__/handler.test.ts index c2a7a80e..7dc04431 100644 --- a/packages/langchain-messages/src/__tests__/handler.test.ts +++ b/packages/langchain-messages/src/__tests__/handler.test.ts @@ -74,6 +74,7 @@ import { ChatAnthropic } from '@langchain/anthropic'; import { ChatBedrockConverse } from '@langchain/aws'; import { ChatOpenAI } from '@langchain/openai'; import { createLangChainHandler } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -1403,11 +1404,10 @@ describe('model source', () => { expect(result.output).toBe('from-instance'); }); - // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so - // `modelConstructorArgs` camelizes the snake_case bag the UI writes before spreading it in - // here — this is the exact conversion the casing fix adds, so this assertion changed from - // `max_tokens: 512` (the old, buggy pass-through) to `maxTokens: 512`. - it('spreads model.parameters into the default OpenAI constructor, camelized', async () => { + // @langchain/openai's ChatOpenAI constructor only reads camelCase (`maxTokens`), so the + // snake_case bag the UI writes is camelized, then narrowed to ChatOpenAI's allowlist: `tools` + // is not a constructor field, so it is dropped. + it('passes allowlisted model.parameters to the default OpenAI constructor, camelized, and drops the rest', async () => { vi.mocked(ChatOpenAI).mockImplementation(function MockChatOpenAI() { return makeMockLLM('default-openai') as any; }); @@ -1419,7 +1419,6 @@ describe('model source', () => { expect(ChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, - tools: ['openai-tool'], model: 'gpt-4o', }); }); @@ -1436,6 +1435,25 @@ describe('model source', () => { expect(ChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, maxTokens: 512, model: 'gpt-4o' }); }); + it.each([ + ['OpenAI', 'gpt-4o', () => ChatOpenAI], + ['Anthropic', 'claude-sonnet-4-5', () => ChatAnthropic], + ['Bedrock', 'anthropic.claude-sonnet-4-5', () => ChatBedrockConverse], + ] as const)('never passes credentials or connection settings to the default %s constructor', async (provider, name, ctor) => { + vi.mocked(ctor()).mockImplementation(function MockChatModel() { + return makeMockLLM('default') as any; + } as any); + const cfg = { + ...baseConfig, + provider: { name: provider }, + model: { name, parameters: { ...NEVER_FORWARDED_PARAMETERS, temperature: 0.3 } }, + }; + await createLangChainHandler()(cfg as any, 'q'); + const args = vi.mocked(ctor()).mock.calls.at(-1)![0] as Record; + expect(args.temperature).toBe(0.3); + expectNoNeverForwardedValue(args); + }); + it('spreads model.parameters into the default Anthropic constructor', async () => { vi.mocked(ChatAnthropic).mockImplementation(function MockChatAnthropic() { return makeMockLLM('default-anthropic') as any; diff --git a/packages/langchain-messages/src/__tests__/model-parameters.test.ts b/packages/langchain-messages/src/__tests__/model-parameters.test.ts new file mode 100644 index 00000000..3b7b2f9e --- /dev/null +++ b/packages/langchain-messages/src/__tests__/model-parameters.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { modelConstructorParameters } from '../model-parameters.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; + +describe('modelConstructorParameters', () => { + it.each(['openai', 'anthropic', 'bedrock'] as const)('drops every never-forwarded key for %s', (modelClass) => { + const args = modelConstructorParameters({ ...NEVER_FORWARDED_PARAMETERS, temperature: 0.5 }, modelClass); + expect(args).toEqual({ temperature: 0.5 }); + expectNoNeverForwardedValue(args); + }); + + it.each(['openai', 'anthropic', 'bedrock'] as const)('drops unknown keys and model for %s', (modelClass) => { + expect( + modelConstructorParameters( + { model: 'smuggled', model_name: 'smuggled', tools: ['x'], unknown_key: 1, callbacks: [] }, + modelClass, + ), + ).toEqual({}); + }); + + it('forwards ChatOpenAI fields under their constructor names', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + max_completion_tokens: 200, + top_p: 0.9, + frequency_penalty: 0.1, + presence_penalty: 0.2, + n: 2, + logit_bias: { '50256': -100 }, + logprobs: true, + top_logprobs: 3, + stop: ['END'], + stream_usage: true, + reasoning: { effort: 'low' }, + verbosity: 'low', + service_tier: 'flex', + prompt_cache_key: 'k', + use_responses_api: true, + user: 'u-1', + tags: ['t'], + metadata: { a: 1 }, + }, + 'openai', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + maxCompletionTokens: 200, + topP: 0.9, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + n: 2, + logitBias: { '50256': -100 }, + logprobs: true, + topLogprobs: 3, + stop: ['END'], + streamUsage: true, + reasoning: { effort: 'low' }, + verbosity: 'low', + // ChatOpenAI reads this one field in snake_case. + service_tier: 'flex', + promptCacheKey: 'k', + useResponsesApi: true, + user: 'u-1', + tags: ['t'], + metadata: { a: 1 }, + }); + }); + + it('forwards ChatAnthropic fields, keeps nested Messages API shapes, and folds effort into outputConfig', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + top_k: 40, + top_p: 0.9, + stop_sequences: ['END'], + thinking: { type: 'enabled', budget_tokens: 1024 }, + context_management: { edits: [{ type: 'clear_tool_uses_20250919' }] }, + inference_geo: 'us', + betas: ['context-1m-2025-08-07'], + effort: 'low', + }, + 'anthropic', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + topK: 40, + topP: 0.9, + stopSequences: ['END'], + // ChatAnthropic sends these to the Messages API as written, so they stay snake_case. + thinking: { type: 'enabled', budget_tokens: 1024 }, + contextManagement: { edits: [{ type: 'clear_tool_uses_20250919' }] }, + inferenceGeo: 'us', + betas: ['context-1m-2025-08-07'], + outputConfig: { effort: 'low' }, + }); + expect(modelConstructorParameters({ effort: 'low', output_config: { effort: 'high' } }, 'anthropic')).toEqual({ + outputConfig: { effort: 'high' }, + }); + }); + + it('forwards ChatBedrockConverse fields under their constructor names', () => { + expect( + modelConstructorParameters( + { + temperature: 0.2, + max_tokens: 100, + top_p: 0.9, + additional_model_request_fields: { top_k: 40 }, + guardrail_config: { guardrailIdentifier: 'g', guardrailVersion: '1' }, + performance_config: { latency: 'optimized' }, + service_tier: 'priority', + supports_tool_choice_values: ['auto'], + application_inference_profile: 'arn:smuggled', + }, + 'bedrock', + ), + ).toEqual({ + temperature: 0.2, + maxTokens: 100, + topP: 0.9, + additionalModelRequestFields: { top_k: 40 }, + guardrailConfig: { guardrailIdentifier: 'g', guardrailVersion: '1' }, + performanceConfig: { latency: 'optimized' }, + serviceTier: 'priority', + supportsToolChoiceValues: ['auto'], + }); + }); +}); diff --git a/packages/langchain-messages/src/__tests__/never-forwarded.ts b/packages/langchain-messages/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/langchain-messages/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/langchain-messages/src/__tests__/wire.test.ts b/packages/langchain-messages/src/__tests__/wire.test.ts index 6bf827d6..2d82e03f 100644 --- a/packages/langchain-messages/src/__tests__/wire.test.ts +++ b/packages/langchain-messages/src/__tests__/wire.test.ts @@ -1,53 +1,83 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLangChainHandler } from '../handler.js'; +import { NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // This file deliberately does NOT mock @langchain/openai. Every other test in this package mocks // it out, so a handler-internal bug that maps a config key to the wrong ChatOpenAI constructor -// field would never reach a real request body — which is exactly how the snake_case bug this -// package's casing fix addresses went unnoticed. Constructing a real ChatOpenAI and intercepting -// its outgoing fetch call is the only check that would have caught it. +// field would never reach a real request body. Constructing a real ChatOpenAI and intercepting +// its outgoing fetch call checks what actually goes on the wire. + +type Captured = { url: string; headers: Record; body: Record }; + +const captured: Captured[] = []; + +beforeEach(() => { + captured.length = 0; + // The customer's own credentials, from the environment, as an application would set them. + vi.stubEnv('OPENAI_API_KEY', 'CUSTOMER'); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown, init?: RequestInit) => { + captured.push({ + url: String(url instanceof Request ? url.url : url), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body: JSON.parse(String(init?.body)), + }); + // A 401 is not retried by either the openai client or LangChain's AsyncCaller. + return new Response(JSON.stringify({ error: { message: 'captured' } }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); const baseConfig = { - model: { - name: 'gpt-4o', - parameters: { - top_p: 0.42, - max_tokens: 321, - }, - }, + model: { name: 'gpt-4o', parameters: { top_p: 0.42, max_tokens: 321 } }, provider: { name: 'LangChain' }, instructions: 'You are helpful.', }; -describe('createLangChainHandler — wire request (real ChatOpenAI, unmocked)', () => { +describe('createLangChainHandler wire request (real ChatOpenAI, unmocked)', () => { it('sends snake_case model.parameters onto the wire as top_p / max_tokens', async () => { - let capturedBody: Record | undefined; - - const fetchFn = async (_url: unknown, init?: { body?: unknown }) => { - capturedBody = JSON.parse(String(init?.body)); - // Abort before any real network call is attempted. - throw new Error('aborted: request captured'); - }; + await expect(createLangChainHandler()(baseConfig as any, 'hi')).rejects.toThrow(); + expect(captured.length).toBeGreaterThan(0); + expect(captured[0].body.top_p).toBe(0.42); + expect(captured[0].body.max_tokens).toBe(321); + }, 10000); + it('keeps the customer key, base URL and headers when a config sets credentials, endpoints and headers', async () => { const config = { ...baseConfig, model: { ...baseConfig.model, parameters: { ...baseConfig.model.parameters, - // `maxRetries: 0` is LangChain's own AsyncCaller retry count (defaults to 6, with - // backoff), separate from the openai client's own maxRetries inside `configuration`. - // Without it a thrown fetch is retried repeatedly and the test times out. - maxRetries: 0, - configuration: { apiKey: 'test-key', fetch: fetchFn }, + ...NEVER_FORWARDED_PARAMETERS, + api_key: 'ATTACKER', + base_url: 'https://attacker.example/v1', + configuration: { + apiKey: 'ATTACKER', + baseURL: 'https://attacker.example/v1', + defaultHeaders: { Authorization: 'Bearer ATTACKER' }, + }, + model_kwargs: { injected: 'NEVER_FORWARDED' }, }, }, }; - await expect(createLangChainHandler()(config as any, 'hi')).rejects.toThrow(); - - expect(capturedBody).toBeDefined(); - expect(capturedBody!.top_p).toBe(0.42); - expect(capturedBody!.max_tokens).toBe(321); - }); + expect(captured.length).toBeGreaterThan(0); + for (const request of captured) { + expect(request.url.startsWith('https://api.openai.com/')).toBe(true); + expect(request.headers.authorization).toBe('Bearer CUSTOMER'); + expect(JSON.stringify(request)).not.toContain('NEVER_FORWARDED'); + expect(JSON.stringify(request)).not.toContain('ATTACKER'); + } + expect(captured[0].body.top_p).toBe(0.42); + }, 10000); }); diff --git a/packages/langchain-messages/src/handler.ts b/packages/langchain-messages/src/handler.ts index fa4d7dd9..479dd86b 100644 --- a/packages/langchain-messages/src/handler.ts +++ b/packages/langchain-messages/src/handler.ts @@ -4,7 +4,6 @@ import { type AiConfigRep, type CanonicalTurn, type ContentCaptureOptions, - camelizeModelParameters, composeHistory, config, createHandler, @@ -20,7 +19,6 @@ import { type Message, type MessageContent, type NativeTool, - normalizeModelParameters, type ProviderHandler, parseTemplate, type SpanUsage, @@ -35,6 +33,7 @@ import { type ToolHandlerFn, } from '@launchdarkly/ai-server'; import { type Context, context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'; +import { type LangChainModelClass, modelConstructorParameters } from './model-parameters.js'; const TRACER_NAME = '@launchdarkly/ai-langchain-messages'; @@ -171,11 +170,16 @@ function normalizeOutputSchema(schema: Record): Record BaseChatModel | Promise); -function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record { - const parameters = { ...camelizeModelParameters(normalizeModelParameters(config.model?.parameters)) }; - if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools; - // Name from the config always wins over a colliding `model` key in the parameter bag. - return { ...parameters, model: resolvedModelName(config, fallbackName) }; +function modelConstructorArgs( + config: AiConfigRep, + fallbackName: string, + modelClass: LangChainModelClass, +): Record { + // Name from the config always wins; `model` is never on a forwarded-keys list anyway. + return { + ...modelConstructorParameters(config.model?.parameters, modelClass), + model: resolvedModelName(config, fallbackName), + }; } /** @@ -184,7 +188,8 @@ function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record * Otherwise, the provider and model name from the AI config are used to * instantiate the appropriate model via a dynamic import, so that neither * @langchain/openai, @langchain/anthropic, nor @langchain/aws is a hard - * dependency. Parameters are passed through unchanged. + * dependency. Only the model class's allowlisted model.parameters are passed; see + * modelConstructorParameters. */ async function resolveBaseModel(config: AiConfigRep, llm?: LangChainModelSource): Promise { const invocation = configForModelCall(config); @@ -201,7 +206,7 @@ async function resolveBaseModel(config: AiConfigRep, llm?: LangChainModelSource) 'Using Anthropic models requires @langchain/anthropic. Install it with: npm install @langchain/anthropic', ); } - return new mod.ChatAnthropic(modelConstructorArgs(invocation, 'claude-3-5-sonnet-20241022')); + return new mod.ChatAnthropic(modelConstructorArgs(invocation, 'claude-3-5-sonnet-20241022', 'anthropic')); } if (providerName === 'bedrock') { // biome-ignore lint/suspicious/noExplicitAny: @langchain/aws loaded via dynamic import with no static types @@ -211,7 +216,7 @@ async function resolveBaseModel(config: AiConfigRep, llm?: LangChainModelSource) } catch { throw new Error('Using Bedrock models requires @langchain/aws. Install it with: npm install @langchain/aws'); } - return new mod.ChatBedrockConverse(modelConstructorArgs(invocation, '')); + return new mod.ChatBedrockConverse(modelConstructorArgs(invocation, '', 'bedrock')); } // biome-ignore lint/suspicious/noExplicitAny: @langchain/openai loaded via dynamic import with no static types let mod: any; @@ -220,7 +225,7 @@ async function resolveBaseModel(config: AiConfigRep, llm?: LangChainModelSource) } catch { throw new Error('Using OpenAI models requires @langchain/openai. Install it with: npm install @langchain/openai'); } - return new mod.ChatOpenAI(modelConstructorArgs(invocation, 'gpt-4o')); + return new mod.ChatOpenAI(modelConstructorArgs(invocation, 'gpt-4o', 'openai')); } const buildTools = ( diff --git a/packages/langchain-messages/src/model-parameters.ts b/packages/langchain-messages/src/model-parameters.ts new file mode 100644 index 00000000..65fea6a9 --- /dev/null +++ b/packages/langchain-messages/src/model-parameters.ts @@ -0,0 +1,146 @@ +import type { ChatAnthropicInput } from '@langchain/anthropic'; +import type { ChatBedrockConverseInput } from '@langchain/aws'; +import type { ChatOpenAIFields } from '@langchain/openai'; +import { + type AiConfigRep, + camelizeModelParameters, + normalizeModelParameters, + pickForwardedModelParameters, +} from '@launchdarkly/ai-server'; + +/** + * Constructor fields every LangChain chat model takes from `BaseChatModelParams` that a config + * can set as JSON. `callbacks`, `callbackManager`, `cache` and `maxConcurrency` are wiring, and + * `maxRetries` is client retry policy, so none of them are forwarded. + */ +const BASE_CHAT_MODEL_KEYS = ['disableStreaming', 'metadata', 'outputVersion', 'tags', 'verbose'] as const; + +/** + * `ChatOpenAI` constructor fields this package takes from `config.model.parameters`, after + * camelizing the bag. Mirrors the Python SDK's `ChatOpenAI` list. + * + * Never forwarded: `apiKey`, `openAIApiKey`, `configuration` (client options, including the base + * URL, default headers, fetch and proxy settings), `timeout`, `maxRetries`, `modelKwargs` (merged + * into the request body as written), `prefixMessages` (prompt content), `model` / `modelName` + * (the handler sets the model), and the client internals `completions`, `responses`, + * `__includeRawResponse` and `supportsStrictToolCalling`. + */ +export const CHAT_OPENAI_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'audio', + 'frequencyPenalty', + 'logitBias', + 'logprobs', + 'maxCompletionTokens', + 'maxTokens', + 'modalities', + 'n', + 'presencePenalty', + 'promptCacheKey', + 'promptCacheRetention', + 'reasoning', + 'service_tier', + 'stop', + 'stopSequences', + 'streamUsage', + 'streaming', + 'temperature', + 'topLogprobs', + 'topP', + 'useResponsesApi', + 'user', + 'verbosity', + 'zdrEnabled', +] as const satisfies ReadonlyArray; + +/** + * `ChatAnthropic` constructor fields this package takes from `config.model.parameters`, after + * camelizing the bag. Mirrors the Python SDK's `ChatAnthropic` list, minus `mcp_servers` (remote + * tools) and `model_kwargs` (request injection), neither of which a config may set. + * + * Never forwarded: `apiKey`, `anthropicApiKey`, `anthropicApiUrl`, `clientOptions` (including + * default headers and fetch), `createClient`, `invocationKwargs` (merged into the request body as + * written), and `model` / `modelName`. + * + * Nested values (`thinking`, `contextManagement`, `outputConfig`) are the Anthropic Messages API's + * own snake_case shapes, which `ChatAnthropic` sends as written, so they are not converted. A + * top-level `effort` (the name the UI writes) becomes `outputConfig.effort`; see + * `modelConstructorParameters`. + */ +export const CHAT_ANTHROPIC_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'betas', + 'contextManagement', + 'inferenceGeo', + 'maxTokens', + 'outputConfig', + 'stopSequences', + 'streamUsage', + 'streaming', + 'temperature', + 'thinking', + 'topK', + 'topP', +] as const satisfies ReadonlyArray; + +/** + * `ChatBedrockConverse` constructor fields this package takes from `config.model.parameters`, + * after camelizing the bag. Mirrors the Python SDK's `ChatBedrockConverse` list, minus + * `region_name`, which is connection configuration. + * + * Never forwarded: `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, + * `bedrockApiSessionToken`, the AWS credential-provider settings (`profile`, `roleArn`, ...), + * `endpointHost`, `client`, `clientOptions`, `defaultHeaders`, `model`, and + * `applicationInferenceProfile` (which replaces the model the handler resolved). + */ +export const CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = [ + ...BASE_CHAT_MODEL_KEYS, + 'additionalModelRequestFields', + 'guardrailConfig', + 'maxTokens', + 'performanceConfig', + 'serviceTier', + 'streamUsage', + 'streaming', + 'supportsToolChoiceValues', + 'temperature', + 'topP', +] as const satisfies ReadonlyArray; + +export type LangChainModelClass = 'openai' | 'anthropic' | 'bedrock'; + +const FORWARDED_KEYS: Record> = { + openai: CHAT_OPENAI_FORWARDED_KEYS, + anthropic: CHAT_ANTHROPIC_FORWARDED_KEYS, + bedrock: CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, +}; + +/** + * `config.model.parameters` as constructor fields for one LangChain chat model class: camelized + * (`top_p` becomes `topP`; nested values are left alone, and snake_case wins when both spellings + * are set), then narrowed to that class's allowlist. Unknown keys are dropped. + * + * Renames before the allowlist is applied: + * - `ChatOpenAI` reads its service tier as `service_tier`, so a camelized `serviceTier` is + * renamed back. + * - `ChatAnthropic` has no `effort` field; a top-level `effort` becomes `outputConfig.effort`. + * An explicit `output_config.effort` wins. + */ +export function modelConstructorParameters( + parameters: AiConfigRep['model']['parameters'] | unknown, + modelClass: LangChainModelClass, +): Record { + const camelized = camelizeModelParameters(normalizeModelParameters(parameters)); + if (modelClass === 'openai' && camelized.serviceTier !== undefined) { + camelized.service_tier = camelized.serviceTier; + delete camelized.serviceTier; + } + if (modelClass === 'anthropic' && camelized.effort !== undefined) { + const outputConfig = + camelized.outputConfig && typeof camelized.outputConfig === 'object' && !Array.isArray(camelized.outputConfig) + ? (camelized.outputConfig as Record) + : {}; + camelized.outputConfig = { effort: camelized.effort, ...outputConfig }; + } + return pickForwardedModelParameters(camelized, FORWARDED_KEYS[modelClass]); +} From 5ea4c0822bc2570c4bd073367f79bf259dc734ab Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:54:58 -0400 Subject: [PATCH 20/23] test(messages): assert claude-messages and openai-messages never forward connection or credential keys --- .../src/__tests__/handler.test.ts | 11 ++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ .../src/__tests__/handler.test.ts | 11 ++ .../src/__tests__/never-forwarded.ts | 161 ++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 packages/claude-messages/src/__tests__/never-forwarded.ts create mode 100644 packages/openai-messages/src/__tests__/never-forwarded.ts diff --git a/packages/claude-messages/src/__tests__/handler.test.ts b/packages/claude-messages/src/__tests__/handler.test.ts index b24ed2f9..81710490 100644 --- a/packages/claude-messages/src/__tests__/handler.test.ts +++ b/packages/claude-messages/src/__tests__/handler.test.ts @@ -58,6 +58,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { }); import { createClaudeMessagesHandler } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -1451,6 +1452,16 @@ describe('createClaudeMessagesHandler — model.parameters forwarding', () => { expect(call.tools).toBeUndefined(); }); + it('never forwards credentials, endpoints, request overrides or remote tools from model.parameters', async () => { + mockMessagesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: NEVER_FORWARDED_PARAMETERS } }; + await createClaudeMessagesHandler()(cfg as any, 'hi'); + expect(mockMessagesCreate).toHaveBeenCalledTimes(1); + for (const arg of mockMessagesCreate.mock.calls[0]) { + expectNoNeverForwardedValue(arg); + } + }); + it('drops an unrecognized model.parameters key rather than forwarding it', async () => { // Unlike the four framework handlers, this handler wraps the raw Anthropic SDK client: an // unsupported key reaching messages.create is a wire-level 400, not a harmlessly ignored extra. diff --git a/packages/claude-messages/src/__tests__/never-forwarded.ts b/packages/claude-messages/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/claude-messages/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} diff --git a/packages/openai-messages/src/__tests__/handler.test.ts b/packages/openai-messages/src/__tests__/handler.test.ts index 2862842e..4d120fb8 100644 --- a/packages/openai-messages/src/__tests__/handler.test.ts +++ b/packages/openai-messages/src/__tests__/handler.test.ts @@ -57,6 +57,7 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => { }); import { createOpenAIHandler } from '../handler.js'; +import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js'; // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -319,6 +320,16 @@ describe('createOpenAIHandler', () => { expect(call.max_output_tokens).toBe(256); }); + it('never forwards credentials, endpoints, request overrides or remote tools from model.parameters', async () => { + mockResponsesCreate.mockResolvedValue(mockFinalResponse()); + const cfg = { ...baseConfig, model: { ...baseConfig.model, parameters: NEVER_FORWARDED_PARAMETERS } }; + await createOpenAIHandler()(cfg as any, 'hi'); + expect(mockResponsesCreate).toHaveBeenCalledTimes(1); + for (const arg of mockResponsesCreate.mock.calls[0]) { + expectNoNeverForwardedValue(arg); + } + }); + it('drops an unrecognized model.parameters key rather than forwarding it', async () => { // Unlike the four framework handlers, this handler wraps the raw Responses API client: an // unsupported key reaching responses.create is a wire-level 400, not a harmlessly ignored extra. diff --git a/packages/openai-messages/src/__tests__/never-forwarded.ts b/packages/openai-messages/src/__tests__/never-forwarded.ts new file mode 100644 index 00000000..11571ee6 --- /dev/null +++ b/packages/openai-messages/src/__tests__/never-forwarded.ts @@ -0,0 +1,161 @@ +import { expect } from 'vitest'; + +/** + * The value every never-forwarded key carries in `NEVER_FORWARDED_PARAMETERS`. A handler that + * passes any of those keys on, under any name or nested anywhere, leaves this string somewhere in + * the options it builds, which is what `expectNoNeverForwardedValue` looks for. + */ +export const NEVER_FORWARDED = 'NEVER_FORWARDED'; + +/** + * Keys that must never reach a provider, framework or host-process option from + * `model.parameters`, in both the snake_case the LaunchDarkly UI writes and the camelCase the + * frameworks read: credentials, endpoints and connection settings, request injection, remote + * tools, and Claude Agents host-process settings. + */ +export const NEVER_FORWARDED_KEYS = [ + // credentials + 'api_key', + 'apiKey', + 'openai_api_key', + 'openAIApiKey', + 'anthropic_api_key', + 'anthropicApiKey', + 'bedrock_api_key', + 'bedrockApiKey', + 'bedrock_api_secret', + 'bedrockApiSecret', + 'credentials', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'aws_access_key_id', + 'awsAccessKeyId', + 'aws_secret_access_key', + 'awsSecretAccessKey', + 'aws_session_token', + 'awsSessionToken', + 'organization', + // endpoints and connection + 'base_url', + 'baseURL', + 'baseUrl', + 'anthropic_api_url', + 'anthropicApiUrl', + 'endpoint', + 'endpoint_host', + 'endpointHost', + 'endpoint_url', + 'endpointUrl', + 'region', + 'region_name', + 'regionName', + 'configuration', + 'client_options', + 'clientOptions', + 'default_headers', + 'defaultHeaders', + 'timeout', + 'max_retries', + 'maxRetries', + 'fetch', + 'http_client', + 'httpClient', + 'http_agent', + 'httpAgent', + 'proxy', + 'proxies', + 'client', + // request injection + 'headers', + 'extra_headers', + 'extraHeaders', + 'extra_body', + 'extraBody', + 'extra_query', + 'extraQuery', + 'model_kwargs', + 'modelKwargs', + 'invocation_kwargs', + 'invocationKwargs', + 'provider_data', + 'providerData', + 'provider_options', + 'providerOptions', + // remote tools + 'mcp_servers', + 'mcpServers', + // Claude Agents host-process settings + 'path_to_claude_code_executable', + 'pathToClaudeCodeExecutable', + 'cli_path', + 'cliPath', + 'executable', + 'executable_args', + 'executableArgs', + 'extra_args', + 'extraArgs', + 'env', + 'cwd', + 'additional_directories', + 'additionalDirectories', + 'add_dirs', + 'addDirs', + 'permission_mode', + 'permissionMode', + 'allow_dangerously_skip_permissions', + 'allowDangerouslySkipPermissions', + 'settings', + 'setting_sources', + 'settingSources', + 'managed_settings', + 'managedSettings', + 'plugins', + 'sandbox', + 'resume', + 'resume_session_at', + 'resumeSessionAt', + 'session_id', + 'sessionId', + 'fork_session', + 'forkSession', + 'continue', + 'hooks', + 'can_use_tool', + 'canUseTool', + 'stderr', + 'abort_controller', + 'abortController', + 'spawn_claude_code_process', + 'spawnClaudeCodeProcess', +] as const; + +/** A `model.parameters` bag that sets every key in `NEVER_FORWARDED_KEYS` to `NEVER_FORWARDED`. */ +export const NEVER_FORWARDED_PARAMETERS: Record = Object.fromEntries( + NEVER_FORWARDED_KEYS.map((key) => [key, NEVER_FORWARDED]), +); + +function findNeverForwarded(value: unknown, path: string, seen: Set): string | undefined { + if (value === NEVER_FORWARDED) return path || '(root)'; + if (!value || typeof value !== 'object' || seen.has(value)) return undefined; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + const found = findNeverForwarded(child, path ? `${path}.${key}` : key, seen); + if (found) return found; + } + return undefined; +} + +/** + * Asserts that no `NEVER_FORWARDED` value appears anywhere in `options`, and that none of the + * `NEVER_FORWARDED_KEYS` the handler does not own itself is set on it. `handlerOwned` lists the + * keys the handler legitimately sets from its own state (Claude Agents sets `mcpServers` and + * `hooks`, for example); those must still not carry the config's value. + */ +export function expectNoNeverForwardedValue(options: unknown, handlerOwned: ReadonlyArray = []): void { + expect(findNeverForwarded(options, '', new Set())).toBeUndefined(); + const record = options as Record; + const leaked = NEVER_FORWARDED_KEYS.filter((key) => !handlerOwned.includes(key) && record[key] !== undefined); + expect(leaked).toEqual([]); +} From c144f125d2fea3dbce015e09780c1f52bc35e81d Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:54:59 -0400 Subject: [PATCH 21/23] docs(client): describe the model.parameters helpers in terms of per-handler allowlists --- packages/client/src/utils.ts | 50 ++++++++++++------------------------ 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index a89a407f..e64eb4f3 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -540,18 +540,11 @@ export function parseJSONWithPossibleFences(rawText: string): T | null { /** * Returns `parameters` unchanged when it is a usable object, `{}` otherwise. * - * The four framework handlers (`claude-agents`, `openai-agents`, `langchain-messages`, - * `langchain-agents`) spread the *entire* `model.parameters` bag straight into their underlying - * framework call rather than picking an allowlist — their frameworks already ignore keys they do - * not recognize, so narrowing here would only drop settings that work today for no safety benefit. - * This is the defensive guard those call sites used to repeat inline: `model.parameters` is - * optional on `AiConfigRep`, and a config's own `model` field is technically reachable as - * `unknown` at these call sites, so both must be checked before spreading. - * - * The two handlers that wrap a raw provider client (`claude-messages`, `openai-messages`) do NOT - * use this: an unrecognized key reaches those providers' wire APIs verbatim and is rejected with a - * 400, so they use `pickForwardedModelParameters` against an explicit, compiler-checked allowlist - * instead. + * `model.parameters` is optional on `AiConfigRep`, and a config's own `model` field is technically + * reachable as `unknown` at handler call sites, so both must be checked before the bag is read. + * This only guards the shape: every handler then narrows the result to its own allowlist with + * `pickForwardedModelParameters`, because `model.parameters` comes from the AI Config and must + * never be able to set credentials, endpoints, headers or host-process settings. */ export function normalizeModelParameters(parameters: unknown): Record { return parameters && typeof parameters === 'object' ? (parameters as Record) : {}; @@ -561,21 +554,12 @@ export function normalizeModelParameters(parameters: unknown): Record( parameters: Record | undefined, @@ -610,14 +594,14 @@ function camelizeKey(key: string): string | undefined { /** * Converts the TOP-LEVEL keys of a `model.parameters` bag from snake_case (the LaunchDarkly UI's - * convention, and the wire name every provider SDK expects) to camelCase, for the four handlers - * whose underlying framework — as opposed to a raw provider client — only reads camelCase option - * names: `max_turns` becomes `maxTurns`, `top_p` becomes `topP`. Every camelCase key generated - * this way, so no lookup table to keep in sync with new provider parameters. + * convention, and the wire name every provider SDK expects) to camelCase, for the handlers whose + * underlying framework, as opposed to a raw provider client, only reads camelCase option names: + * `max_turns` becomes `maxTurns`, `top_p` becomes `topP`. Callers narrow the result to their + * allowlist afterwards, so camelizing never makes a key forwardable by itself. * * Only top-level keys convert. A nested value such as `thinking: { budget_tokens: 1024 }` is - * copied through untouched, because these frameworks hand that inner object to the provider API - * raw, snake_case and all. + * copied through untouched; a handler whose SDK reads a nested camelCase shape rebuilds that value + * itself. * * When a bag sets both spellings of one key (`max_turns` and `maxTurns`), the snake_case one * wins — it is the convention the UI writes, so it is treated as authoritative — deterministically From f6268fdc2c186efd68f91831afba8224974b6bee Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 19:06:02 -0400 Subject: [PATCH 22/23] fix(langchain): stop forwarding additionalModelRequestFields to ChatBedrockConverse It is merged into the Bedrock request body as written, so it is request injection, like invocationKwargs. The never-forwarded test fixture in every package now includes it. --- packages/claude-agents/src/__tests__/never-forwarded.ts | 2 ++ packages/claude-messages/src/__tests__/never-forwarded.ts | 2 ++ .../langchain-agents/src/__tests__/model-parameters.test.ts | 2 -- packages/langchain-agents/src/__tests__/never-forwarded.ts | 2 ++ packages/langchain-agents/src/model-parameters.ts | 4 ++-- .../langchain-messages/src/__tests__/model-parameters.test.ts | 2 -- packages/langchain-messages/src/__tests__/never-forwarded.ts | 2 ++ packages/langchain-messages/src/model-parameters.ts | 4 ++-- packages/openai-agents/src/__tests__/never-forwarded.ts | 2 ++ packages/openai-messages/src/__tests__/never-forwarded.ts | 2 ++ packages/vercel-agents/src/__tests__/never-forwarded.ts | 2 ++ packages/vercel-messages/src/__tests__/never-forwarded.ts | 2 ++ 12 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/claude-agents/src/__tests__/never-forwarded.ts b/packages/claude-agents/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/claude-agents/src/__tests__/never-forwarded.ts +++ b/packages/claude-agents/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/claude-messages/src/__tests__/never-forwarded.ts b/packages/claude-messages/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/claude-messages/src/__tests__/never-forwarded.ts +++ b/packages/claude-messages/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/langchain-agents/src/__tests__/model-parameters.test.ts b/packages/langchain-agents/src/__tests__/model-parameters.test.ts index 3b7b2f9e..883d206a 100644 --- a/packages/langchain-agents/src/__tests__/model-parameters.test.ts +++ b/packages/langchain-agents/src/__tests__/model-parameters.test.ts @@ -112,7 +112,6 @@ describe('modelConstructorParameters', () => { temperature: 0.2, max_tokens: 100, top_p: 0.9, - additional_model_request_fields: { top_k: 40 }, guardrail_config: { guardrailIdentifier: 'g', guardrailVersion: '1' }, performance_config: { latency: 'optimized' }, service_tier: 'priority', @@ -125,7 +124,6 @@ describe('modelConstructorParameters', () => { temperature: 0.2, maxTokens: 100, topP: 0.9, - additionalModelRequestFields: { top_k: 40 }, guardrailConfig: { guardrailIdentifier: 'g', guardrailVersion: '1' }, performanceConfig: { latency: 'optimized' }, serviceTier: 'priority', diff --git a/packages/langchain-agents/src/__tests__/never-forwarded.ts b/packages/langchain-agents/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/langchain-agents/src/__tests__/never-forwarded.ts +++ b/packages/langchain-agents/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/langchain-agents/src/model-parameters.ts b/packages/langchain-agents/src/model-parameters.ts index 65fea6a9..9c56c87e 100644 --- a/packages/langchain-agents/src/model-parameters.ts +++ b/packages/langchain-agents/src/model-parameters.ts @@ -88,14 +88,14 @@ export const CHAT_ANTHROPIC_FORWARDED_KEYS = [ * after camelizing the bag. Mirrors the Python SDK's `ChatBedrockConverse` list, minus * `region_name`, which is connection configuration. * - * Never forwarded: `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, + * Never forwarded: `additionalModelRequestFields` (merged into the request body as written), + * `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, * `bedrockApiSessionToken`, the AWS credential-provider settings (`profile`, `roleArn`, ...), * `endpointHost`, `client`, `clientOptions`, `defaultHeaders`, `model`, and * `applicationInferenceProfile` (which replaces the model the handler resolved). */ export const CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = [ ...BASE_CHAT_MODEL_KEYS, - 'additionalModelRequestFields', 'guardrailConfig', 'maxTokens', 'performanceConfig', diff --git a/packages/langchain-messages/src/__tests__/model-parameters.test.ts b/packages/langchain-messages/src/__tests__/model-parameters.test.ts index 3b7b2f9e..883d206a 100644 --- a/packages/langchain-messages/src/__tests__/model-parameters.test.ts +++ b/packages/langchain-messages/src/__tests__/model-parameters.test.ts @@ -112,7 +112,6 @@ describe('modelConstructorParameters', () => { temperature: 0.2, max_tokens: 100, top_p: 0.9, - additional_model_request_fields: { top_k: 40 }, guardrail_config: { guardrailIdentifier: 'g', guardrailVersion: '1' }, performance_config: { latency: 'optimized' }, service_tier: 'priority', @@ -125,7 +124,6 @@ describe('modelConstructorParameters', () => { temperature: 0.2, maxTokens: 100, topP: 0.9, - additionalModelRequestFields: { top_k: 40 }, guardrailConfig: { guardrailIdentifier: 'g', guardrailVersion: '1' }, performanceConfig: { latency: 'optimized' }, serviceTier: 'priority', diff --git a/packages/langchain-messages/src/__tests__/never-forwarded.ts b/packages/langchain-messages/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/langchain-messages/src/__tests__/never-forwarded.ts +++ b/packages/langchain-messages/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/langchain-messages/src/model-parameters.ts b/packages/langchain-messages/src/model-parameters.ts index 65fea6a9..9c56c87e 100644 --- a/packages/langchain-messages/src/model-parameters.ts +++ b/packages/langchain-messages/src/model-parameters.ts @@ -88,14 +88,14 @@ export const CHAT_ANTHROPIC_FORWARDED_KEYS = [ * after camelizing the bag. Mirrors the Python SDK's `ChatBedrockConverse` list, minus * `region_name`, which is connection configuration. * - * Never forwarded: `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, + * Never forwarded: `additionalModelRequestFields` (merged into the request body as written), + * `region`, `credentials`, `bedrockApiKey`, `bedrockApiSecret`, * `bedrockApiSessionToken`, the AWS credential-provider settings (`profile`, `roleArn`, ...), * `endpointHost`, `client`, `clientOptions`, `defaultHeaders`, `model`, and * `applicationInferenceProfile` (which replaces the model the handler resolved). */ export const CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = [ ...BASE_CHAT_MODEL_KEYS, - 'additionalModelRequestFields', 'guardrailConfig', 'maxTokens', 'performanceConfig', diff --git a/packages/openai-agents/src/__tests__/never-forwarded.ts b/packages/openai-agents/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/openai-agents/src/__tests__/never-forwarded.ts +++ b/packages/openai-agents/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/openai-messages/src/__tests__/never-forwarded.ts b/packages/openai-messages/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/openai-messages/src/__tests__/never-forwarded.ts +++ b/packages/openai-messages/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/vercel-agents/src/__tests__/never-forwarded.ts b/packages/vercel-agents/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/vercel-agents/src/__tests__/never-forwarded.ts +++ b/packages/vercel-agents/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', diff --git a/packages/vercel-messages/src/__tests__/never-forwarded.ts b/packages/vercel-messages/src/__tests__/never-forwarded.ts index 11571ee6..300d6e51 100644 --- a/packages/vercel-messages/src/__tests__/never-forwarded.ts +++ b/packages/vercel-messages/src/__tests__/never-forwarded.ts @@ -79,6 +79,8 @@ export const NEVER_FORWARDED_KEYS = [ 'modelKwargs', 'invocation_kwargs', 'invocationKwargs', + 'additional_model_request_fields', + 'additionalModelRequestFields', 'provider_data', 'providerData', 'provider_options', From 532a0eef6046e3ea9fbc31c093d7b2d052976e7b Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 19:06:03 -0400 Subject: [PATCH 23/23] docs(langchain): stop showing model.parameters spread into a model constructor --- packages/langchain-agents/README.md | 7 ++++++- packages/langchain-messages/README.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/langchain-agents/README.md b/packages/langchain-agents/README.md index 6ec6484b..f26163dd 100644 --- a/packages/langchain-agents/README.md +++ b/packages/langchain-agents/README.md @@ -48,10 +48,15 @@ be built after evaluation: ```ts const handler = createLangChainAgentsHandler((config) => new ChatAnthropic({ model: config.model.name, - ...config.model.parameters, + temperature: typeof config.model.parameters?.temperature === 'number' ? config.model.parameters.temperature : undefined, })); ``` +Pick out the settings you want by name. Don't spread `config.model.parameters` into the constructor: +it comes from the AI Config, and a spread would let it set constructor options such as `apiKey`, +`anthropicApiUrl` or `clientOptions`. If you pass no model, the handler builds one itself and +forwards only an allowlist of generation settings. + ### Convenience wrapper ```ts diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index ee465125..1cd59999 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -45,10 +45,15 @@ be built after evaluation: ```ts const handler = createLangChainHandler((config) => new ChatAnthropic({ model: config.model.name, - ...config.model.parameters, + temperature: typeof config.model.parameters?.temperature === 'number' ? config.model.parameters.temperature : undefined, })); ``` +Pick out the settings you want by name. Don't spread `config.model.parameters` into the constructor: +it comes from the AI Config, and a spread would let it set constructor options such as `apiKey`, +`anthropicApiUrl` or `clientOptions`. If you pass no model, the handler builds one itself and +forwards only an allowlist of generation settings. + ### Convenience wrapper ```ts