Files
qinglong/packages/ql3-ai/src/model-gateway/validation.ts
T

423 lines
13 KiB
TypeScript

import { Buffer } from 'node:buffer';
import {
MAX_MODEL_INPUT_BYTES,
MAX_MODEL_INVOCATION_MS,
MAX_MODEL_MESSAGE_BYTES,
MAX_MODEL_MESSAGES,
MAX_MODEL_OUTPUT_BYTES,
MAX_MODEL_OUTPUT_TOKENS,
MODEL_FINISH_REASONS,
MODEL_MESSAGE_ROLES,
type GenerateRequest,
type GenerateResult,
type ModelChunk,
type ModelInfo,
type ModelInvocationContext,
type ModelInvocationPolicy,
type ModelMessage,
type ModelUsage,
} from './model';
import { normalizeModelInvocationProjectQuotaPolicy } from '../usage/usageQuota';
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
const REVISION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export class InvalidModelValueError extends TypeError {
readonly code = 'MODEL_VALUE_INVALID';
constructor(message: string) {
super(`Model value is invalid: ${message}`);
this.name = 'InvalidModelValueError';
}
}
function assertExactKeys(
value: object,
expected: readonly string[],
name: string,
): void {
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new InvalidModelValueError(`${name} shape is invalid`);
}
}
function normalizeIdentifier(value: unknown, name: string): string {
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
throw new InvalidModelValueError(`${name} is invalid`);
}
return value;
}
function normalizeNonNegativeInteger(value: unknown, name: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new InvalidModelValueError(`${name} is invalid`);
}
return value as number;
}
function normalizePositiveInteger(
value: unknown,
name: string,
maximum: number,
): number {
const normalized = normalizeNonNegativeInteger(value, name);
if (normalized < 1 || normalized > maximum) {
throw new InvalidModelValueError(`${name} is out of range`);
}
return normalized;
}
function normalizeMessage(value: ModelMessage): Readonly<ModelMessage> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('message must be an object');
}
assertExactKeys(value, ['role', 'content'], 'message');
if (!MODEL_MESSAGE_ROLES.includes(value.role)) {
throw new InvalidModelValueError('message role is invalid');
}
if (
typeof value.content !== 'string' ||
Buffer.byteLength(value.content, 'utf8') > MAX_MODEL_MESSAGE_BYTES
) {
throw new InvalidModelValueError('message content is invalid');
}
return Object.freeze({ role: value.role, content: value.content });
}
export function normalizeGenerateRequest(
value: GenerateRequest,
): Readonly<GenerateRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('request must be an object');
}
const expected = ['provider', 'model', 'messages', 'maxOutputTokens'];
if (Object.hasOwn(value, 'temperature')) expected.push('temperature');
assertExactKeys(value, expected, 'request');
const provider = normalizeIdentifier(value.provider, 'provider');
const model = normalizeIdentifier(value.model, 'model');
if (
!Array.isArray(value.messages) ||
value.messages.length < 1 ||
value.messages.length > MAX_MODEL_MESSAGES
) {
throw new InvalidModelValueError('messages are invalid');
}
const messages = Object.freeze(value.messages.map(normalizeMessage));
if (measureModelInputBytes(messages) > MAX_MODEL_INPUT_BYTES) {
throw new InvalidModelValueError('input exceeds the hard byte limit');
}
const maxOutputTokens = normalizePositiveInteger(
value.maxOutputTokens,
'maxOutputTokens',
MAX_MODEL_OUTPUT_TOKENS,
);
if (
value.temperature !== undefined &&
(typeof value.temperature !== 'number' ||
!Number.isFinite(value.temperature) ||
value.temperature < 0 ||
value.temperature > 2)
) {
throw new InvalidModelValueError('temperature is invalid');
}
return Object.freeze({
provider,
model,
messages,
maxOutputTokens,
...(value.temperature === undefined
? {}
: { temperature: value.temperature }),
});
}
export function normalizeModelInvocationContext(
value: ModelInvocationContext,
nowMs: number,
): Readonly<ModelInvocationContext> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('context must be an object');
}
const expected = [
'projectId',
'runId',
'stepRunId',
'traceId',
'requestId',
'deadlineAtMs',
];
if (Object.hasOwn(value, 'signal')) expected.push('signal');
assertExactKeys(value, expected, 'context');
if (
!Number.isSafeInteger(nowMs) ||
!Number.isSafeInteger(value.deadlineAtMs) ||
value.deadlineAtMs <= nowMs ||
value.deadlineAtMs - nowMs > MAX_MODEL_INVOCATION_MS
) {
throw new InvalidModelValueError('deadlineAtMs is invalid');
}
if (
value.signal !== undefined &&
(!(value.signal instanceof AbortSignal) || value.signal.aborted)
) {
throw new InvalidModelValueError('signal is invalid');
}
return Object.freeze({
projectId: normalizeIdentifier(value.projectId, 'projectId'),
runId: normalizeIdentifier(value.runId, 'runId'),
stepRunId: normalizeIdentifier(value.stepRunId, 'stepRunId'),
traceId: normalizeIdentifier(value.traceId, 'traceId'),
requestId: normalizeIdentifier(value.requestId, 'requestId'),
deadlineAtMs: value.deadlineAtMs,
...(value.signal === undefined ? {} : { signal: value.signal }),
});
}
export function normalizeModelInvocationPolicy(
value: ModelInvocationPolicy,
): Readonly<ModelInvocationPolicy> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('policy must be an object');
}
const expected = [
'revision',
'allowedProviders',
'allowedModels',
'maxInputBytes',
'maxOutputBytes',
'maxOutputTokens',
'maxTotalTokens',
'maxCostMicros',
'priceRevision',
];
if (Object.hasOwn(value, 'projectQuota')) expected.push('projectQuota');
assertExactKeys(value, expected, 'policy');
if (
typeof value.revision !== 'string' ||
!REVISION_PATTERN.test(value.revision)
) {
throw new InvalidModelValueError('policy revision is invalid');
}
const normalizeAllowlist = (
entries: readonly string[],
name: string,
): readonly string[] => {
if (!Array.isArray(entries) || entries.length < 1 || entries.length > 128) {
throw new InvalidModelValueError(`${name} is invalid`);
}
const normalized = entries.map((entry) => normalizeIdentifier(entry, name));
if (new Set(normalized).size !== normalized.length) {
throw new InvalidModelValueError(`${name} contains duplicates`);
}
return Object.freeze(normalized);
};
const maxInputBytes = normalizePositiveInteger(
value.maxInputBytes,
'maxInputBytes',
MAX_MODEL_INPUT_BYTES,
);
const maxOutputBytes = normalizePositiveInteger(
value.maxOutputBytes,
'maxOutputBytes',
MAX_MODEL_OUTPUT_BYTES,
);
const maxOutputTokens = normalizePositiveInteger(
value.maxOutputTokens,
'maxOutputTokens',
MAX_MODEL_OUTPUT_TOKENS,
);
const maxTotalTokens = normalizePositiveInteger(
value.maxTotalTokens,
'maxTotalTokens',
MAX_MODEL_OUTPUT_TOKENS * 16,
);
if (maxOutputTokens > maxTotalTokens) {
throw new InvalidModelValueError('maxOutputTokens exceeds maxTotalTokens');
}
if (
value.maxCostMicros !== null &&
(!Number.isSafeInteger(value.maxCostMicros) || value.maxCostMicros < 0)
) {
throw new InvalidModelValueError('maxCostMicros is invalid');
}
if (
value.priceRevision !== null &&
(typeof value.priceRevision !== 'string' ||
!REVISION_PATTERN.test(value.priceRevision))
) {
throw new InvalidModelValueError('priceRevision is invalid');
}
return Object.freeze({
revision: value.revision,
allowedProviders: normalizeAllowlist(
value.allowedProviders,
'allowedProviders',
),
allowedModels: normalizeAllowlist(value.allowedModels, 'allowedModels'),
maxInputBytes,
maxOutputBytes,
maxOutputTokens,
maxTotalTokens,
maxCostMicros: value.maxCostMicros,
priceRevision: value.priceRevision,
...(value.projectQuota === undefined
? {}
: {
projectQuota: normalizeModelInvocationProjectQuotaPolicy(
value.projectQuota,
),
}),
});
}
export function normalizeModelUsage(value: ModelUsage): Readonly<ModelUsage> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('usage must be an object');
}
const expected = ['inputTokens', 'outputTokens', 'totalTokens'];
if (Object.hasOwn(value, 'costMicros')) expected.push('costMicros');
assertExactKeys(value, expected, 'usage');
const inputTokens = normalizeNonNegativeInteger(
value.inputTokens,
'inputTokens',
);
const outputTokens = normalizeNonNegativeInteger(
value.outputTokens,
'outputTokens',
);
const totalTokens = normalizeNonNegativeInteger(
value.totalTokens,
'totalTokens',
);
if (totalTokens !== inputTokens + outputTokens) {
throw new InvalidModelValueError('totalTokens is inconsistent');
}
if (
value.costMicros !== undefined &&
(!Number.isSafeInteger(value.costMicros) || value.costMicros < 0)
) {
throw new InvalidModelValueError('costMicros is invalid');
}
return Object.freeze({
inputTokens,
outputTokens,
totalTokens,
...(value.costMicros === undefined ? {} : { costMicros: value.costMicros }),
});
}
export function normalizeGenerateResult(
value: GenerateResult,
): Readonly<GenerateResult> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('result must be an object');
}
assertExactKeys(
value,
['provider', 'model', 'text', 'finishReason', 'usage'],
'result',
);
if (
typeof value.text !== 'string' ||
Buffer.byteLength(value.text, 'utf8') > MAX_MODEL_OUTPUT_BYTES
) {
throw new InvalidModelValueError('result text is invalid');
}
if (!MODEL_FINISH_REASONS.includes(value.finishReason)) {
throw new InvalidModelValueError('finishReason is invalid');
}
return Object.freeze({
provider: normalizeIdentifier(value.provider, 'result provider'),
model: normalizeIdentifier(value.model, 'result model'),
text: value.text,
finishReason: value.finishReason,
usage: normalizeModelUsage(value.usage),
});
}
export function normalizeModelChunk(value: ModelChunk): Readonly<ModelChunk> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('chunk must be an object');
}
const expected = ['delta'];
if (Object.hasOwn(value, 'finishReason')) expected.push('finishReason');
if (Object.hasOwn(value, 'usage')) expected.push('usage');
assertExactKeys(value, expected, 'chunk');
if (
typeof value.delta !== 'string' ||
Buffer.byteLength(value.delta, 'utf8') > MAX_MODEL_OUTPUT_BYTES
) {
throw new InvalidModelValueError('chunk delta is invalid');
}
if (
value.finishReason !== undefined &&
!MODEL_FINISH_REASONS.includes(value.finishReason)
) {
throw new InvalidModelValueError('chunk finishReason is invalid');
}
return Object.freeze({
delta: value.delta,
...(value.finishReason === undefined
? {}
: { finishReason: value.finishReason }),
...(value.usage === undefined
? {}
: { usage: normalizeModelUsage(value.usage) }),
});
}
export function normalizeModelInfo(value: ModelInfo): Readonly<ModelInfo> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidModelValueError('model info must be an object');
}
const expected = ['id'];
if (Object.hasOwn(value, 'displayName')) expected.push('displayName');
if (Object.hasOwn(value, 'contextWindowTokens')) {
expected.push('contextWindowTokens');
}
assertExactKeys(value, expected, 'model info');
const id = normalizeIdentifier(value.id, 'model id');
if (
value.displayName !== undefined &&
(typeof value.displayName !== 'string' ||
value.displayName.length < 1 ||
Buffer.byteLength(value.displayName, 'utf8') > 256)
) {
throw new InvalidModelValueError('model displayName is invalid');
}
if (
value.contextWindowTokens !== undefined &&
(!Number.isSafeInteger(value.contextWindowTokens) ||
value.contextWindowTokens < 1)
) {
throw new InvalidModelValueError('contextWindowTokens is invalid');
}
return Object.freeze({
id,
...(value.displayName === undefined
? {}
: { displayName: value.displayName }),
...(value.contextWindowTokens === undefined
? {}
: { contextWindowTokens: value.contextWindowTokens }),
});
}
export function measureModelInputBytes(
messages: readonly ModelMessage[],
): number {
return messages.reduce(
(total, message) =>
total +
Buffer.byteLength(message.role, 'utf8') +
Buffer.byteLength(message.content, 'utf8'),
0,
);
}