fix(ai-roles): validate chatModel + guard driver-enum drift (#52)

chatModel was a free string accepted with empty/garbage values, failing only at
runtime as a provider 503; tighten it (trim + non-empty + max 200). Driver was
already @IsIn(AI_DRIVERS). Collapse the client driver list to one AI_DRIVER_VALUES
source and add a contract test that reads the server AI_DRIVERS and fails on
client/server drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 03:28:58 +03:00
parent c0d312d8f5
commit 342bb47b30
4 changed files with 163 additions and 8 deletions

View File

@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateAgentRoleDto, RoleModelConfigDto } from './agent-role.dto';
/**
* API-boundary validation for the role model override. The key invariants:
* - `driver`, when present, must be a supported server driver (AI_DRIVERS);
* - `chatModel`, when present, must be a non-empty, trimmed, bounded string —
* empty/whitespace-only garbage is rejected here, not at provider runtime.
*/
describe('RoleModelConfigDto validation', () => {
function validateConfig(config: unknown) {
const dto = plainToInstance(RoleModelConfigDto, config);
return validateSync(dto as object);
}
it('accepts a supported driver + non-empty chatModel', () => {
expect(validateConfig({ driver: 'openai', chatModel: 'gpt-4o' })).toHaveLength(
0,
);
});
it('accepts an empty object (omitted override => workspace default)', () => {
expect(validateConfig({})).toHaveLength(0);
});
it('rejects an unknown driver', () => {
const errors = validateConfig({ driver: 'anthropic', chatModel: 'x' });
expect(errors.some((e) => e.property === 'driver')).toBe(true);
});
it('rejects an empty chatModel string', () => {
const errors = validateConfig({ chatModel: '' });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
it('rejects a whitespace-only chatModel (trimmed to empty)', () => {
const errors = validateConfig({ chatModel: ' ' });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
it('trims surrounding whitespace from chatModel', () => {
const dto = plainToInstance(RoleModelConfigDto, {
chatModel: ' gpt-4o-mini ',
});
expect(validateSync(dto as object)).toHaveLength(0);
expect(dto.chatModel).toBe('gpt-4o-mini');
});
it('rejects a chatModel longer than 200 chars', () => {
const errors = validateConfig({ chatModel: 'a'.repeat(201) });
expect(errors.some((e) => e.property === 'chatModel')).toBe(true);
});
});
describe('CreateAgentRoleDto with nested modelConfig', () => {
function validateCreate(payload: unknown) {
const dto = plainToInstance(CreateAgentRoleDto, payload);
return validateSync(dto as object);
}
const base = { name: 'Researcher', instructions: 'Do research.' };
it('accepts a valid create payload with a model override', () => {
expect(
validateCreate({
...base,
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
}),
).toHaveLength(0);
});
it('rejects a create payload whose nested chatModel is blank', () => {
const errors = validateCreate({
...base,
modelConfig: { chatModel: ' ' },
});
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -5,9 +5,10 @@ import {
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, TransformFnParams, Type } from 'class-transformer';
import { AI_DRIVERS, AiDriver } from '../../../../integrations/ai/ai.types';
/**
@@ -20,8 +21,16 @@ export class RoleModelConfigDto {
@IsIn(AI_DRIVERS)
driver?: AiDriver;
// Free-form provider model id (providers add models constantly, so we don't
// pin an allow-list). We still reject empty/whitespace-only garbage at the API
// boundary: trim first, then require a non-empty, bounded string. An invalid
// model still surfaces as a clear provider 503 at resolve time, not here.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(1)
@MaxLength(200)
chatModel?: string;
}