99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
export type LlmRole = 'user' | 'assistant'
|
|
|
|
export interface LlmMessage {
|
|
role: LlmRole
|
|
content: string
|
|
}
|
|
|
|
export interface ValidatedLlmRequest {
|
|
messages: LlmMessage[]
|
|
system?: string
|
|
max_tokens: number
|
|
model?: string
|
|
stream: boolean
|
|
}
|
|
|
|
const MAX_MESSAGES = 40
|
|
const MAX_MESSAGE_CHARS = 8_000
|
|
const MAX_TOTAL_CHARS = 50_000
|
|
const MAX_SYSTEM_CHARS = 8_000
|
|
const DEFAULT_MAX_TOKENS = 1_024
|
|
const MAX_OUTPUT_TOKENS = 4_096
|
|
|
|
export class LlmRequestError extends Error {
|
|
readonly status = 400
|
|
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'LlmRequestError'
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
export function parseLlmRequest(value: unknown): ValidatedLlmRequest {
|
|
if (!isRecord(value)) throw new LlmRequestError('Invalid JSON body')
|
|
if (!Array.isArray(value.messages) || value.messages.length === 0) {
|
|
throw new LlmRequestError('At least one message is required')
|
|
}
|
|
if (value.messages.length > MAX_MESSAGES) {
|
|
throw new LlmRequestError(`At most ${MAX_MESSAGES} messages are allowed`)
|
|
}
|
|
|
|
let totalChars = 0
|
|
const messages = value.messages.map((entry, index): LlmMessage => {
|
|
if (!isRecord(entry) || (entry.role !== 'user' && entry.role !== 'assistant')) {
|
|
throw new LlmRequestError(`Invalid role at message ${index}`)
|
|
}
|
|
if (typeof entry.content !== 'string') {
|
|
throw new LlmRequestError(`Invalid content at message ${index}`)
|
|
}
|
|
const content = entry.content.trim()
|
|
if (content.length === 0 || content.length > MAX_MESSAGE_CHARS) {
|
|
throw new LlmRequestError(`Invalid content length at message ${index}`)
|
|
}
|
|
totalChars += content.length
|
|
if (totalChars > MAX_TOTAL_CHARS) throw new LlmRequestError('Conversation is too large')
|
|
return { role: entry.role, content }
|
|
})
|
|
|
|
let system: string | undefined
|
|
if (value.system !== undefined) {
|
|
if (typeof value.system !== 'string') throw new LlmRequestError('Invalid system prompt')
|
|
system = value.system.trim()
|
|
if (system.length === 0 || system.length > MAX_SYSTEM_CHARS) {
|
|
throw new LlmRequestError('Invalid system prompt length')
|
|
}
|
|
}
|
|
|
|
const maxTokens = value.max_tokens === undefined ? DEFAULT_MAX_TOKENS : value.max_tokens
|
|
if (!Number.isSafeInteger(maxTokens) || Number(maxTokens) < 1 || Number(maxTokens) > MAX_OUTPUT_TOKENS) {
|
|
throw new LlmRequestError(`max_tokens must be between 1 and ${MAX_OUTPUT_TOKENS}`)
|
|
}
|
|
if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) {
|
|
throw new LlmRequestError('Invalid model')
|
|
}
|
|
if (value.stream !== undefined && typeof value.stream !== 'boolean') {
|
|
throw new LlmRequestError('Invalid stream flag')
|
|
}
|
|
|
|
return {
|
|
messages,
|
|
system,
|
|
max_tokens: Number(maxTokens),
|
|
model: typeof value.model === 'string' ? value.model.trim() : undefined,
|
|
stream: value.stream === true,
|
|
}
|
|
}
|
|
|
|
export function hasAssistantText(value: unknown): boolean {
|
|
if (!isRecord(value) || !Array.isArray(value.content)) return false
|
|
return value.content.some((block) => (
|
|
isRecord(block)
|
|
&& block.type === 'text'
|
|
&& typeof block.text === 'string'
|
|
&& block.text.trim().length > 0
|
|
))
|
|
}
|