diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 992e8d19f..93d66ac19 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -113,6 +113,7 @@ async function main() { apiKeyArn?: string; efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; + apiFormat?: 'converse_stream' | 'responses' | 'chat_completions'; }[] = []; for (const entry of specAny.harnesses ?? []) { const harnessDir = path.resolve(projectRoot, entry.path); @@ -131,6 +132,7 @@ async function main() { apiKeyArn: harnessSpec.model?.apiKeyArn, efsAccessPoints: harnessSpec.efsAccessPoints, s3AccessPoints: harnessSpec.s3AccessPoints, + apiFormat: harnessSpec.model?.apiFormat, }); } catch (err) { throw new Error( diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index f2518baf3..987d1234b 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -68,6 +68,7 @@ async function main() { apiKeyArn?: string; efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; + apiFormat?: 'converse_stream' | 'responses' | 'chat_completions'; }[] = []; for (const entry of specAny.harnesses ?? []) { const harnessDir = path.resolve(projectRoot, entry.path); @@ -86,6 +87,7 @@ async function main() { apiKeyArn: harnessSpec.model?.apiKeyArn, efsAccessPoints: harnessSpec.efsAccessPoints, s3AccessPoints: harnessSpec.s3AccessPoints, + apiFormat: harnessSpec.model?.apiFormat, }); } catch (err) { throw new Error( diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index 608e52dde..55f321f43 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -19,6 +19,7 @@ export type HarnessStatus = 'CREATING' | 'READY' | 'UPDATING' | 'DELETING' | 'DE export interface BedrockModelConfig { modelId: string; + apiFormat?: 'converse_stream' | 'responses' | 'chat_completions'; temperature?: number; topP?: number; maxTokens?: number; @@ -27,6 +28,7 @@ export interface BedrockModelConfig { export interface OpenAiModelConfig { modelId: string; apiKeyArn?: string; + apiFormat?: 'responses' | 'chat_completions'; temperature?: number; topP?: number; maxTokens?: number; diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 5ec6e044a..6bb3b95b8 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -97,6 +97,7 @@ export interface AddHarnessCliOptions { name?: string; modelProvider?: string; modelId?: string; + apiFormat?: string; apiKeyArn?: string; container?: string; memory?: boolean; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index cf339257c..8b509b6e5 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -19,6 +19,7 @@ import { getSupportedModelProviders, isValidKmsKeyArn, matchEnumValue, + validateApiFormat, } from '../../../schema'; import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils'; import { validateHeaderAllowlist } from '../shared/header-utils'; @@ -892,6 +893,14 @@ const VALID_HARNESS_TOOLS = [ const VALID_GATEWAY_OUTBOUND_AUTH = ['awsIam', 'none', 'oauth'] as const; export function validateAddHarnessOptions(options: AddHarnessCliOptions): ValidationResult { + if (options.apiFormat) { + const provider = options.modelProvider ?? 'bedrock'; + const formatResult = validateApiFormat(options.apiFormat, provider); + if (!formatResult.valid) { + return { valid: false, error: formatResult.error }; + } + } + if (options.tools) { const toolNames = options.tools.split(',').map(s => s.trim()); for (const tool of toolNames) { diff --git a/src/cli/commands/create/__tests__/harness-validate.test.ts b/src/cli/commands/create/__tests__/harness-validate.test.ts index 3e1cd5898..61ab2d87c 100644 --- a/src/cli/commands/create/__tests__/harness-validate.test.ts +++ b/src/cli/commands/create/__tests__/harness-validate.test.ts @@ -26,6 +26,92 @@ const vpcOptions = { securityGroups: 'sg-07234e16e36d51629', }; +// ───────────────────────────────────────────────────────────────────────────── +// apiFormat validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('validateCreateHarnessOptions - apiFormat', () => { + it('accepts valid apiFormat for bedrock provider', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, apiFormat: 'responses' }, makeCwd()); + expect(result.valid).toBe(true); + }); + + it('accepts chat_completions format', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, apiFormat: 'chat_completions' }, makeCwd()); + expect(result.valid).toBe(true); + }); + + it('accepts converse_stream format', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, apiFormat: 'converse_stream' }, makeCwd()); + expect(result.valid).toBe(true); + }); + + it('rejects invalid apiFormat value', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, apiFormat: 'invalid_format' }, makeCwd()); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid API format'); + }); + + it('accepts responses format for open_ai provider', () => { + const result = validateCreateHarnessOptions( + { + ...baseOptions, + modelProvider: 'open_ai', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'responses', + }, + makeCwd() + ); + expect(result.valid).toBe(true); + }); + + it('accepts chat_completions format for open_ai provider', () => { + const result = validateCreateHarnessOptions( + { + ...baseOptions, + modelProvider: 'open_ai', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'chat_completions', + }, + makeCwd() + ); + expect(result.valid).toBe(true); + }); + + it('rejects converse_stream for open_ai provider', () => { + const result = validateCreateHarnessOptions( + { + ...baseOptions, + modelProvider: 'open_ai', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'converse_stream', + }, + makeCwd() + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid API format for open_ai'); + }); + + it('rejects apiFormat for gemini provider', () => { + const result = validateCreateHarnessOptions( + { + ...baseOptions, + modelProvider: 'gemini', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'responses', + }, + makeCwd() + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('only supported for bedrock and open_ai'); + }); + + it('passes when apiFormat is not specified', () => { + const result = validateCreateHarnessOptions(baseOptions, makeCwd()); + expect(result.valid).toBe(true); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // EFS access point validation // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts index 86bdd1161..3354ae641 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -1,5 +1,5 @@ import { CONFIG_DIR } from '../../../lib'; -import type { HarnessModelProvider, NetworkMode } from '../../../schema'; +import type { HarnessApiFormat, HarnessModelProvider, NetworkMode } from '../../../schema'; import { harnessPrimitive } from '../../primitives/registry'; import { type ProgressCallback, createProject } from './action'; import type { CreateResult } from './types'; @@ -12,6 +12,7 @@ export interface CreateHarnessProjectOptions { cwd: string; modelProvider: HarnessModelProvider; modelId: string; + apiFormat?: HarnessApiFormat; apiKeyArn?: string; skipMemory?: boolean; containerUri?: string; @@ -59,6 +60,7 @@ export async function createProjectWithHarness(options: CreateHarnessProjectOpti name: options.name, modelProvider: options.modelProvider, modelId: options.modelId, + apiFormat: options.apiFormat, apiKeyArn: options.apiKeyArn, containerUri: options.containerUri, dockerfilePath: options.dockerfilePath, diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts index f31f01870..feedf0784 100644 --- a/src/cli/commands/create/harness-validate.ts +++ b/src/cli/commands/create/harness-validate.ts @@ -1,4 +1,4 @@ -import { MAX_EFS_MOUNTS, MAX_S3_MOUNTS } from '../../../schema'; +import { MAX_EFS_MOUNTS, MAX_S3_MOUNTS, validateApiFormat } from '../../../schema'; import { HarnessNameSchema, ProjectNameSchema } from '../../../schema'; import { validateAccessPointMounts, @@ -13,6 +13,7 @@ export interface CreateHarnessCliOptions { projectName?: string; modelProvider?: string; modelId?: string; + apiFormat?: string; apiKeyArn?: string; container?: string; noMemory?: boolean; @@ -102,6 +103,13 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; } + if (options.apiFormat) { + const formatResult = validateApiFormat(options.apiFormat, options.modelProvider ?? 'bedrock'); + if (!formatResult.valid) { + return { valid: false, error: formatResult.error }; + } + } + // Validate EFS access point ARN/path pairs const efsArns = options.efsAccessPointArn ?? []; const efsPaths = options.efsMountPath ?? []; diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts index ab9c0bb1e..bd9ec876f 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts @@ -87,6 +87,95 @@ describe('mapHarnessSpecToCreateOptions', () => { }); }); + it('maps bedrock with apiFormat responses', async () => { + const opts = baseOptions({ + harnessSpec: { + name: 'h', + model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'responses' }, + tools: [], + skills: [], + } as any, + }); + const result = await mapHarnessSpecToCreateOptions(opts); + expect(result.model).toEqual({ + bedrockModelConfig: { modelId: 'openai.gpt-oss-120b', apiFormat: 'responses' }, + }); + }); + + it('maps bedrock with apiFormat chat_completions', async () => { + const opts = baseOptions({ + harnessSpec: { + name: 'h', + model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions' }, + tools: [], + skills: [], + } as any, + }); + const result = await mapHarnessSpecToCreateOptions(opts); + expect(result.model).toEqual({ + bedrockModelConfig: { modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions' }, + }); + }); + + it('omits apiFormat when converse_stream (default)', async () => { + const opts = baseOptions({ + harnessSpec: { + name: 'h', + model: { provider: 'bedrock', modelId: 'claude', apiFormat: 'converse_stream' }, + tools: [], + skills: [], + } as any, + }); + const result = await mapHarnessSpecToCreateOptions(opts); + expect(result.model).toEqual({ + bedrockModelConfig: { modelId: 'claude' }, + }); + }); + + it('maps open_ai with apiFormat chat_completions', async () => { + const opts = baseOptions({ + harnessSpec: { + name: 'h', + model: { + provider: 'open_ai', + modelId: 'gpt-5', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'chat_completions', + }, + tools: [], + skills: [], + } as any, + }); + const result = await mapHarnessSpecToCreateOptions(opts); + expect(result.model).toEqual({ + openAiModelConfig: { + modelId: 'gpt-5', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'chat_completions', + }, + }); + }); + + it('omits apiFormat for open_ai when responses (default)', async () => { + const opts = baseOptions({ + harnessSpec: { + name: 'h', + model: { + provider: 'open_ai', + modelId: 'gpt-5', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'responses', + }, + tools: [], + skills: [], + } as any, + }); + const result = await mapHarnessSpecToCreateOptions(opts); + expect(result.model).toEqual({ + openAiModelConfig: { modelId: 'gpt-5', apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key' }, + }); + }); + it('includes optional model params when set', async () => { const opts = baseOptions({ harnessSpec: { diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts index 51a34760f..165cc5d19 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -153,13 +153,14 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): // ============================================================================ function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { - const { provider, modelId, apiKeyArn, temperature, topP, topK, maxTokens } = model; + const { provider, modelId, apiKeyArn, apiFormat, temperature, topP, topK, maxTokens } = model; switch (provider) { case 'bedrock': return { bedrockModelConfig: { modelId, + ...(apiFormat && apiFormat !== 'converse_stream' && { apiFormat }), ...(temperature !== undefined && { temperature }), ...(topP !== undefined && { topP }), ...(maxTokens !== undefined && { maxTokens }), @@ -170,6 +171,7 @@ function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { openAiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }), + ...(apiFormat && apiFormat !== 'responses' && { apiFormat: apiFormat as 'responses' | 'chat_completions' }), ...(temperature !== undefined && { temperature }), ...(topP !== undefined && { topP }), ...(maxTokens !== undefined && { maxTokens }), diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts index 5164d148e..cf5acd083 100644 --- a/src/cli/primitives/HarnessPrimitive.ts +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -1,5 +1,6 @@ import { APP_DIR, ConfigIO, type Result, findConfigRoot } from '../../lib'; import type { + HarnessApiFormat, HarnessGatewayOutboundAuth, HarnessModelProvider, HarnessSpec, @@ -27,6 +28,7 @@ export interface AddHarnessOptions { name: string; modelProvider: HarnessModelProvider; modelId: string; + apiFormat?: HarnessApiFormat; apiKeyArn?: string; systemPrompt?: string; skipMemory?: boolean; @@ -151,6 +153,7 @@ export class HarnessPrimitive extends BasePrimitive', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)') .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini') .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') + .option( + '--api-format ', + 'API format: converse_stream, responses, chat_completions (bedrock); responses, chat_completions (open_ai)' + ) .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') .option('--container ', 'Container image URI or path to a Dockerfile') .option('--no-memory', 'Skip auto-creating memory') @@ -394,6 +401,7 @@ export class HarnessPrimitive extends BasePrimitive + (wizard.config.modelProvider === 'open_ai' ? OPENAI_API_FORMAT_OPTIONS : BEDROCK_API_FORMAT_OPTIONS).map(opt => ({ + id: opt.id, + title: opt.title, + description: opt.description, + })), + [wizard.config.modelProvider] + ); + const containerModeItems: SelectableItem[] = useMemo( () => CONTAINER_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), [] @@ -102,6 +114,7 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isNameStep = wizard.step === 'name'; const isModelProviderStep = wizard.step === 'model-provider'; + const isApiFormatStep = wizard.step === 'api-format'; const isApiKeyArnStep = wizard.step === 'api-key-arn'; const isContainerStep = wizard.step === 'container'; const isContainerUriStep = wizard.step === 'container-uri'; @@ -142,6 +155,13 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A isActive: isModelProviderStep, }); + const apiFormatNav = useListNavigation({ + items: apiFormatItems, + onSelect: item => wizard.setApiFormat(HarnessApiFormatSchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isApiFormatStep, + }); + const containerModeNav = useListNavigation({ items: containerModeItems, onSelect: item => wizard.setContainerMode(item.id as ContainerMode), @@ -220,6 +240,7 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A : isAdvancedStep || isToolsSelectStep ? 'Space toggle · Enter confirm · Esc back' : isModelProviderStep || + isApiFormatStep || isMemoryStep || isContainerStep || isNetworkModeStep || @@ -240,6 +261,10 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A { label: 'Model ID', value: wizard.config.modelId }, ]; + if (wizard.config.apiFormat) { + fields.push({ label: 'API Format', value: wizard.config.apiFormat }); + } + if (wizard.config.apiKeyArn) { fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); } @@ -418,6 +443,15 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A /> )} + {isApiFormatStep && ( + + )} + {isApiKeyArnStep && ( = { name: 'Name', 'model-provider': 'Model provider', + 'api-format': 'API format', 'api-key-arn': 'API key ARN', container: 'Custom environment', 'container-uri': 'Container URI', @@ -114,6 +117,8 @@ export const DEFAULT_MODEL_IDS: Record = { gemini: 'gemini-2.5-flash', }; +export const DEFAULT_BEDROCK_MANTLE_MODEL_ID = 'openai.gpt-oss-120b'; + export const MODEL_PROVIDER_OPTIONS = [ { id: 'bedrock' as const, title: 'Amazon Bedrock', description: `Default: ${DEFAULT_MODEL_IDS.bedrock}` }, { @@ -128,6 +133,39 @@ export const MODEL_PROVIDER_OPTIONS = [ }, ] as const; +export const BEDROCK_API_FORMAT_OPTIONS = [ + { + id: 'converse_stream' as const, + title: 'Converse Stream', + description: 'Standard Bedrock Converse API (default)', + }, + { + id: 'responses' as const, + title: 'Responses', + description: 'OpenAI Responses API via Bedrock Mantle', + }, + { + id: 'chat_completions' as const, + title: 'Chat Completions', + description: 'OpenAI Chat Completions API via Bedrock Mantle', + }, +] as const; + +export const OPENAI_API_FORMAT_OPTIONS = [ + { + id: 'responses' as const, + title: 'Responses', + description: 'OpenAI Responses API (default)', + }, + { + id: 'chat_completions' as const, + title: 'Chat Completions', + description: 'OpenAI Chat Completions API', + }, +] as const; + +export const API_FORMAT_OPTIONS = BEDROCK_API_FORMAT_OPTIONS; + export const TRUNCATION_STRATEGY_OPTIONS = [ { id: 'sliding_window' as const, title: 'Sliding window', description: 'Keep most recent messages' }, { id: 'summarization' as const, title: 'Summarization', description: 'Compress older context' }, diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts index 0d3da4a48..f181ec8b6 100644 --- a/src/cli/tui/screens/harness/useAddHarnessWizard.ts +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -1,8 +1,9 @@ -import type { HarnessModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; +import type { HarnessApiFormat, HarnessModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; +import { isPreviewEnabled } from '../../../feature-flags'; import type { JwtConfig } from '../../components/jwt-config'; import { HARNESS_FILESYSTEM_STEP_NAMES, useFilesystemMountState } from '../../hooks/useFilesystemMountState'; import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting, ContainerMode } from './types'; -import { DEFAULT_MODEL_IDS } from './types'; +import { DEFAULT_BEDROCK_MANTLE_MODEL_ID, DEFAULT_MODEL_IDS } from './types'; import { useCallback, useMemo, useState } from 'react'; const ADVANCED_SETTING_ORDER: AdvancedSetting[] = [ @@ -57,6 +58,10 @@ export function useAddHarnessWizard() { const allSteps = useMemo(() => { const steps: AddHarnessStep[] = ['name', 'model-provider']; + if ((config.modelProvider === 'bedrock' || config.modelProvider === 'open_ai') && isPreviewEnabled()) { + steps.push('api-format'); + } + if (config.modelProvider !== 'bedrock') { steps.push('api-key-arn'); } @@ -249,14 +254,31 @@ export function useAddHarnessWizard() { ); const setModelProvider = useCallback((modelProvider: HarnessModelProvider) => { - setConfig(c => ({ ...c, modelProvider, modelId: DEFAULT_MODEL_IDS[modelProvider] })); - if (modelProvider !== 'bedrock') { + setConfig(c => ({ ...c, modelProvider, modelId: DEFAULT_MODEL_IDS[modelProvider], apiFormat: undefined })); + if (modelProvider === 'bedrock' && isPreviewEnabled()) { + setStep('api-format'); + } else if (modelProvider !== 'bedrock') { setStep('api-key-arn'); } else { setStep('container'); } }, []); + const setApiFormat = useCallback((apiFormat: HarnessApiFormat) => { + setConfig(c => { + if (c.modelProvider === 'bedrock') { + const isMantle = apiFormat !== 'converse_stream'; + return { + ...c, + apiFormat: isMantle ? apiFormat : undefined, + modelId: isMantle ? DEFAULT_BEDROCK_MANTLE_MODEL_ID : DEFAULT_MODEL_IDS.bedrock, + }; + } + return { ...c, apiFormat }; + }); + setStep('container'); + }, []); + const setApiKeyArn = useCallback( (apiKeyArn: string) => { setConfig(c => ({ ...c, apiKeyArn })); @@ -532,6 +554,7 @@ export function useAddHarnessWizard() { goBack, setName, setModelProvider, + setApiFormat, setApiKeyArn, setContainerMode, setContainerUri, diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 4ef4458a6..648513824 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -79,19 +79,26 @@ export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primit export type { HttpGatewayTarget } from './primitives/http-gateway'; export { HttpGatewayTargetSchema } from './primitives/http-gateway'; export type { + BedrockApiFormat, + HarnessApiFormat, HarnessGatewayOutboundAuth, HarnessMemoryRef, HarnessModel, - HarnessSpec, HarnessModelProvider, + HarnessSpec, + OpenAiApiFormat, } from './primitives/harness'; export { + BedrockApiFormatSchema, + HarnessApiFormatSchema, + OpenAiApiFormatSchema, GatewayOAuthGrantTypeSchema, HarnessGatewayOutboundAuthSchema, + HarnessModelProviderSchema, HarnessNameSchema, HarnessSpecSchema, HarnessToolTypeSchema, - HarnessModelProviderSchema, + validateApiFormat, } from './primitives/harness'; // ============================================================================ diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts index 8ec96a1c8..4b63fd774 100644 --- a/src/schema/schemas/primitives/__tests__/harness.test.ts +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -691,4 +691,94 @@ describe('HarnessSpecSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts bedrock model with apiFormat responses', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'responses' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts bedrock model with apiFormat chat_completions', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts bedrock model with apiFormat converse_stream', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { provider: 'bedrock', modelId: 'anthropic.claude-v2', apiFormat: 'converse_stream' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts apiFormat responses for open_ai provider', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'responses', + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts apiFormat chat_completions for open_ai provider', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'chat_completions', + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects converse_stream for open_ai provider', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'converse_stream', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Invalid API format for open_ai'))).toBe(true); + } + }); + + it('rejects apiFormat for gemini provider', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { + provider: 'gemini', + modelId: 'gemini-2.5-flash', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', + apiFormat: 'responses', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only supported for bedrock and open_ai'))).toBe(true); + } + }); + + it('rejects invalid apiFormat value', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + model: { provider: 'bedrock', modelId: 'anthropic.claude-v2', apiFormat: 'invalid_format' }, + }); + expect(result.success).toBe(false); + }); }); diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts index 132c0c0c4..4531774c2 100644 --- a/src/schema/schemas/primitives/harness.ts +++ b/src/schema/schemas/primitives/harness.ts @@ -30,11 +30,21 @@ export const HarnessNameSchema = z export const HarnessModelProviderSchema = z.enum(['bedrock', 'open_ai', 'gemini']); export type HarnessModelProvider = z.infer; +export const BedrockApiFormatSchema = z.enum(['converse_stream', 'responses', 'chat_completions']); +export type BedrockApiFormat = z.infer; + +export const OpenAiApiFormatSchema = z.enum(['responses', 'chat_completions']); +export type OpenAiApiFormat = z.infer; + +export const HarnessApiFormatSchema = z.enum(['converse_stream', 'responses', 'chat_completions']); +export type HarnessApiFormat = z.infer; + export const HarnessModelSchema = z .object({ provider: HarnessModelProviderSchema, modelId: z.string().min(1, 'Model ID is required'), apiKeyArn: z.string().optional(), + apiFormat: HarnessApiFormatSchema.optional(), temperature: z.number().min(0).max(2).optional(), topP: z.number().min(0).max(1).optional(), topK: z.number().min(0).max(1).optional(), @@ -48,10 +58,48 @@ export const HarnessModelSchema = z path: ['topK'], }); } + if (model.apiFormat !== undefined) { + if (model.provider !== 'bedrock' && model.provider !== 'open_ai') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '--api-format is only supported for bedrock and open_ai providers', + path: ['apiFormat'], + }); + } else if (model.provider === 'open_ai' && model.apiFormat === 'converse_stream') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid API format for open_ai: ${model.apiFormat}. Use ${OpenAiApiFormatSchema.options.join(', ')}`, + path: ['apiFormat'], + }); + } + } }); export type HarnessModel = z.infer; +export function validateApiFormat( + apiFormat: string, + provider: string +): { valid: true } | { valid: false; error: string } { + const allFormats = HarnessApiFormatSchema.options as readonly string[]; + if (!allFormats.includes(apiFormat)) { + return { valid: false, error: `Invalid API format: ${apiFormat}. Use ${allFormats.join(', ')}` }; + } + if (provider !== 'bedrock' && provider !== 'open_ai') { + return { valid: false, error: '--api-format is only supported for bedrock and open_ai providers' }; + } + const result = HarnessModelSchema.safeParse({ provider, modelId: 'placeholder', apiFormat }); + if (result.success) return { valid: true }; + const apiFormatIssue = result.error.issues.find(i => i.path.includes('apiFormat')); + if (apiFormatIssue) { + return { + valid: false, + error: `Invalid API format for ${provider}: ${apiFormat}. Use ${(provider === 'open_ai' ? OpenAiApiFormatSchema : BedrockApiFormatSchema).options.join(', ')}`, + }; + } + return { valid: true }; +} + // ============================================================================ // Tool Configuration // ============================================================================ diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index b25fe3666..d67c6285b 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -72,6 +72,8 @@ export { } from './policy'; export type { + BedrockApiFormat, + HarnessApiFormat, HarnessGatewayOutboundAuth, HarnessMemoryRef, HarnessModel, @@ -80,9 +82,13 @@ export type { HarnessTool, HarnessToolType, HarnessTruncationConfig, + OpenAiApiFormat, } from './harness'; export { AllowedToolSchema, + BedrockApiFormatSchema, + HarnessApiFormatSchema, + OpenAiApiFormatSchema, GatewayOAuthGrantTypeSchema, HarnessGatewayOutboundAuthSchema, HarnessMemoryRefSchema,