feat(server): extract check params (#12187)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Improved input validation and error reporting for chat messages, attachments, and embeddings, with clearer error messages for invalid inputs.
  - Enhanced support for multimodal messages, including attachments such as images or audio.

- **Refactor**
  - Unified and streamlined parameter validation across AI providers, resulting in more consistent behavior and error handling.
  - Centralized parameter checks into a common provider layer, removing duplicate validation code from individual AI providers.

- **Tests**
  - Simplified and consolidated audio transcription test stubs for better maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
darkskygit
2025-05-22 13:43:59 +00:00
parent 5035ab218d
commit 477250f1b8
8 changed files with 114 additions and 206 deletions
@@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import {
Config,
@@ -14,10 +15,13 @@ import {
CopilotProviderModel,
CopilotProviderType,
CopilotStructuredOptions,
EmbeddingMessage,
ModelCapability,
ModelConditions,
ModelFullConditions,
ModelInputType,
type PromptMessage,
PromptMessageSchema,
} from './types';
@Injectable()
@@ -60,7 +64,8 @@ export abstract class CopilotProvider<C = any> {
const { modelId, outputType, inputTypes } = cond;
const matcher = (cap: ModelCapability) =>
(!outputType || cap.output.includes(outputType)) &&
(!inputTypes || inputTypes.every(type => cap.input.includes(type)));
(!inputTypes?.length ||
inputTypes.every(type => cap.input.includes(type)));
if (modelId) {
return this.models.find(
@@ -93,6 +98,65 @@ export abstract class CopilotProvider<C = any> {
);
}
private handleZodError(ret: z.SafeParseReturnType<any, any>) {
if (ret.success) return;
const issues = ret.error.issues.map(i => {
const path =
'root' +
(i.path.length
? `.${i.path.map(seg => (typeof seg === 'number' ? `[${seg}]` : `.${seg}`)).join('')}`
: '');
return `${i.message}${path}`;
});
throw new CopilotPromptInvalid(issues.join('; '));
}
protected async checkParams({
cond,
messages,
embeddings,
options = {},
}: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?: CopilotChatOptions;
}) {
const model = this.selectModel(cond);
const multimodal = model.capabilities.some(c =>
[ModelInputType.Image, ModelInputType.Audio].some(t =>
c.input.includes(t)
)
);
if (messages) {
const { requireContent = true, requireAttachment = false } = options;
const MessageSchema = z
.array(
PromptMessageSchema.extend({
content: requireContent
? z.string().trim().min(1)
: z.string().optional().nullable(),
})
.passthrough()
.catchall(z.union([z.string(), z.number(), z.date(), z.null()]))
.refine(
m =>
!(multimodal && requireAttachment && m.role === 'user') ||
(m.attachments ? m.attachments.length > 0 : true),
{ message: 'attachments required in multimodal mode' }
)
)
.optional();
this.handleZodError(MessageSchema.safeParse(messages));
}
if (embeddings) {
this.handleZodError(EmbeddingMessage.safeParse(embeddings));
}
}
abstract text(
model: ModelConditions,
messages: PromptMessage[],