Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a9bc905
fix(claude-agents): forward maxTurns and model.parameters to query()
apucacao Sep 18, 2026
f6cb53e
fix(openai-messages): forward model.parameters to the Responses API
apucacao Sep 22, 2026
23aea7d
fix(claude-messages): forward model.parameters to messages.create/stream
apucacao Sep 22, 2026
1f14632
fix(openai-agents): forward maxTurns and model.parameters to Runner.run
apucacao Sep 22, 2026
6dfac3b
refactor(client): share model-parameter picking and normalizing helpers
apucacao Sep 22, 2026
cf9a611
refactor(handlers): drop model.parameters allowlists, pass the whole …
apucacao Sep 22, 2026
0c36840
refactor(claude-agents): pass model parameters as an explicit argument
apucacao Sep 22, 2026
75c6287
fix(handlers): camelize snake_case model.parameters for framework han…
apucacao Sep 24, 2026
cad26b4
docs(handlers): say the agent handlers camelize model.parameters, not…
apucacao Sep 24, 2026
f7f85d0
feat(client): restore pickForwardedModelParameters for closed provide…
apucacao Sep 24, 2026
5c51dc9
feat(openai-messages): forward only Responses API keys from model.par…
apucacao Sep 24, 2026
9a1819e
feat(claude-messages): forward only Messages API keys from model.para…
apucacao Sep 24, 2026
39e342d
fix(openai-messages): only exclude model.parameters keys that would b…
apucacao Sep 24, 2026
74b0d9b
Merge remote-tracking branch 'origin/main' into alexis/forward-max-turns
apucacao Oct 2, 2026
d2a52ae
fix(vercel-messages): map model.parameters onto AI SDK call settings
apucacao Oct 2, 2026
4241d75
fix(vercel-agents): map model.parameters onto ToolLoopAgent call sett…
apucacao Oct 2, 2026
0a616fb
fix(vercel): never forward headers, providerOptions, maxRetries or ti…
apucacao Oct 5, 2026
37bd8a7
fix(claude-agents): forward only an allowlist of query() options from…
apucacao Oct 5, 2026
398e935
fix(openai-agents): forward only an allowlist of ModelSettings fields…
apucacao Oct 5, 2026
0990a51
fix(langchain): forward only a per-model-class allowlist of construct…
apucacao Oct 5, 2026
5ea4c08
test(messages): assert claude-messages and openai-messages never forw…
apucacao Oct 5, 2026
c144f12
docs(client): describe the model.parameters helpers in terms of per-h…
apucacao Oct 5, 2026
f6268fd
fix(langchain): stop forwarding additionalModelRequestFields to ChatB…
apucacao Oct 5, 2026
532a0ee
docs(langchain): stop showing model.parameters spread into a model co…
apucacao Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions packages/claude-agents/src/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -189,6 +190,179 @@ 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);
});

// 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 = {
...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('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, maxTurns: 2 } },
};
await createClaudeAgentsHandler()(config as any, 'q');
const { options } = mockQuery.mock.calls[0][0];
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 () => {
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 () => {
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 () => {
Expand Down Expand Up @@ -492,6 +666,44 @@ 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('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: {} };
});
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 } };
Expand Down
40 changes: 40 additions & 0 deletions packages/claude-agents/src/__tests__/native-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<unknown>)({ 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<unknown>)({ 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(
Expand Down
Loading
Loading