d3ro-voice/server/supabase/functions/_shared/llm-contract.ts
2026-08-29 18:33:45 +09:00

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
))
}