diff --git a/src/cli/operations/dev/web-ui/__tests__/harness-utils.test.ts b/src/cli/operations/dev/web-ui/__tests__/harness-utils.test.ts index 62c6bf4c3..504b0037f 100644 --- a/src/cli/operations/dev/web-ui/__tests__/harness-utils.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/harness-utils.test.ts @@ -33,14 +33,60 @@ describe('buildInvokeOptions — model resolution', () => { expect(opts.skills).toEqual([{ path: './skills' }]); }); - it('sends no default override for non-bedrock providers', () => { + it('defaults an OpenAI model with maxTokens + apiKeyArn (dropping converse_stream apiFormat)', () => { + const openAiModel: HarnessModel = { + provider: 'open_ai', + modelId: 'gpt-4.1', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/openai', + maxTokens: 2048, + temperature: 0.5, + }; + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], undefined, openAiModel); + expect(opts.model).toEqual({ + openAiModelConfig: { + modelId: 'gpt-4.1', + temperature: 0.5, + maxTokens: 2048, + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/openai', + }, + }); + }); + + it('defaults a Gemini model with maxTokens + topK', () => { const geminiModel: HarnessModel = { provider: 'gemini', modelId: 'gemini-2.5-flash', apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/gemini', maxTokens: 512, + topK: 40, }; const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], undefined, geminiModel); - expect(opts.model).toBeUndefined(); + expect(opts.model).toEqual({ + geminiModelConfig: { + modelId: 'gemini-2.5-flash', + maxTokens: 512, + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/gemini', + topK: 40, + }, + }); + }); + + it('defaults a LiteLLM model with maxTokens + apiBase + additionalParams', () => { + const liteLlmModel: HarnessModel = { + provider: 'lite_llm', + modelId: 'bedrock/us.anthropic.claude-sonnet-4-5', + maxTokens: 8192, + apiBase: 'https://proxy.example.com', + additionalParams: { foo: 'bar' }, + }; + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], undefined, liteLlmModel); + expect(opts.model).toEqual({ + liteLlmModelConfig: { + modelId: 'bedrock/us.anthropic.claude-sonnet-4-5', + maxTokens: 8192, + apiBase: 'https://proxy.example.com', + additionalParams: { foo: 'bar' }, + }, + }); }); }); diff --git a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts index 8a8ac29ff..cf99ef4bf 100644 --- a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts +++ b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts @@ -1,10 +1,63 @@ import { ConfigIO } from '../../../../../lib'; import type { HarnessModel } from '../../../../../schema'; -import type { HarnessSystemPrompt, InvokeHarnessOptions } from '../../../../aws/agentcore-harness'; +import type { + HarnessModelConfiguration, + HarnessSystemPrompt, + InvokeHarnessOptions, +} from '../../../../aws/agentcore-harness'; import type { HarnessInvocationOverrides } from '../api-types'; const DEFAULT_MAX_ITERATIONS = 75; +/** + * Map the local harness spec's model onto the invoke API's model-configuration shape, so dev + * invocations honor agentcore.json model settings (notably maxTokens) without a redeploy. The web + * UI only sends a model override when the user edits the model panel — without this default the + * deployed model config silently wins. Returns undefined for an unknown provider. + */ +function buildModelOverride(specModel: HarnessModel): HarnessModelConfiguration | undefined { + // Fields common to every provider config. + const common = { + modelId: specModel.modelId, + ...(specModel.temperature !== undefined && { temperature: specModel.temperature }), + ...(specModel.topP !== undefined && { topP: specModel.topP }), + ...(specModel.maxTokens !== undefined && { maxTokens: specModel.maxTokens }), + }; + + switch (specModel.provider) { + case 'bedrock': + return { bedrockModelConfig: { ...common, ...(specModel.apiFormat && { apiFormat: specModel.apiFormat }) } }; + case 'open_ai': + return { + openAiModelConfig: { + ...common, + ...(specModel.apiKeyArn && { apiKeyArn: specModel.apiKeyArn }), + // OpenAI accepts only 'responses' | 'chat_completions'; the schema rejects converse_stream here. + ...(specModel.apiFormat && specModel.apiFormat !== 'converse_stream' && { apiFormat: specModel.apiFormat }), + }, + }; + case 'gemini': + return { + geminiModelConfig: { + ...common, + ...(specModel.apiKeyArn && { apiKeyArn: specModel.apiKeyArn }), + ...(specModel.topK !== undefined && { topK: specModel.topK }), + }, + }; + case 'lite_llm': + return { + liteLlmModelConfig: { + ...common, + ...(specModel.apiKeyArn && { apiKeyArn: specModel.apiKeyArn }), + ...(specModel.apiBase && { apiBase: specModel.apiBase }), + ...(specModel.additionalParams && { additionalParams: specModel.additionalParams }), + }, + }; + default: + return undefined; + } +} + /** * Read the model config from the local harness spec, best-effort. Returns undefined when there is * no config root or the spec is unreadable — the invocation then uses the deployed model config. @@ -40,19 +93,10 @@ export function buildInvokeOptions( if (overrides?.model) { opts.model = overrides.model; - } else if (specModel?.provider === 'bedrock') { - // The web UI only sends a model override when the user edits the model panel, so without this - // default the deployed model config silently wins during dev. Send the local spec's Bedrock - // model config so agentcore.json settings (notably maxTokens) apply without a redeploy. - opts.model = { - bedrockModelConfig: { - modelId: specModel.modelId, - ...(specModel.apiFormat && { apiFormat: specModel.apiFormat }), - ...(specModel.temperature !== undefined && { temperature: specModel.temperature }), - ...(specModel.topP !== undefined && { topP: specModel.topP }), - ...(specModel.maxTokens !== undefined && { maxTokens: specModel.maxTokens }), - }, - }; + } else if (specModel) { + // Default the model from the local spec so agentcore.json settings (notably maxTokens) apply in + // dev without a redeploy. See buildModelOverride for the per-provider mapping. + opts.model = buildModelOverride(specModel); } if (overrides?.systemPrompt) opts.systemPrompt = [{ text: overrides.systemPrompt }] as HarnessSystemPrompt; if (overrides?.skills) opts.skills = overrides.skills;