Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/ai

src/validation.ts

179 lines · 4.7 KB · TypeScript
export type InputRecord = Record<string, unknown>

export type ProviderId =
	| 'openai'
	| 'anthropic'
	| 'groq'
	| 'cloudflare'
	| 'openai-compatible'

export const PROVIDER_IDS = [
	'openai',
	'anthropic',
	'groq',
	'cloudflare',
	'openai-compatible',
] as const satisfies ReadonlyArray<ProviderId>

const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
const SECRET_PREFIXES = [
	'openaiApiKey',
	'anthropicApiKey',
	'groqApiKey',
	'cloudflareApiToken',
	'openaiCompatibleApiKey',
] as const

export type SecretPrefix = (typeof SECRET_PREFIXES)[number]

export function inputRecord(value: unknown): InputRecord {
	if (value === null || typeof value !== 'object' || Array.isArray(value)) {
		throw new Error('Input must be an object.')
	}
	return value as InputRecord
}

export function optionalString(
	input: InputRecord,
	key: string,
): string | undefined {
	const value = input[key]
	if (value === undefined || value === null || value === '') return undefined
	if (typeof value !== 'string') throw new Error(key + ' must be a string.')
	const trimmed = value.trim()
	return trimmed || undefined
}

export function requiredString(input: InputRecord, key: string): string {
	const value = optionalString(input, key)
	if (!value) throw new Error(key + ' is required.')
	return value
}

export function optionalBoolean(
	input: InputRecord,
	key: string,
): boolean | undefined {
	const value = input[key]
	if (value === undefined || value === null) return undefined
	if (typeof value !== 'boolean') throw new Error(key + ' must be a boolean.')
	return value
}

export function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

export function isProviderId(value: string): value is ProviderId {
	return (PROVIDER_IDS as ReadonlyArray<string>).includes(value)
}

export function parseProviderId(value: string | undefined): ProviderId | undefined {
	if (!value) return undefined
	if (!isProviderId(value)) {
		throw new Error(
			'provider must be openai, anthropic, groq, cloudflare, or openai-compatible.',
		)
	}
	return value
}

export function secretNameFor(
	prefix: SecretPrefix,
	account?: string,
	override?: string,
): string {
	if (override != null && override.trim()) {
		const name = override.trim()
		const allowed = SECRET_PREFIXES.some(
			(item) => name === item || name.startsWith(item + '-'),
		)
		if (!allowed) {
			throw new Error(
				'apiKeySecret must be one of ' +
					SECRET_PREFIXES.join(', ') +
					' or the same name with an account suffix.',
			)
		}
		return name
	}
	const label = accountLabel(account)
	return label ? prefix + '-' + label : prefix
}

export function secretPlaceholder(secretName: string): string {
	return '{{secret:' + secretName + '|scope=user}}'
}

/**
 * Settings writes default to dry-run. A live persist requires `confirm: true`
 * and `dryRun` not set to true.
 */
export function isMutationDryRun(input: {
	dryRun?: unknown
	confirm?: unknown
}): boolean {
	if (input.dryRun === true) return true
	if (input.confirm === true) return false
	return true
}

export function isPreviewDryRun(input: { dryRun?: unknown }): boolean {
	return input.dryRun === true
}

export function boundedMaxTokens(value: unknown, fallback = 4096): number {
	if (value === undefined || value === null || value === '') return fallback
	if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 32768) {
		throw new Error('maxTokens must be an integer from 1 through 32768.')
	}
	return Number(value)
}

export function assertHttpsUrl(value: string, key: string): string {
	let parsed: URL
	try {
		parsed = new URL(value)
	} catch {
		throw new Error(key + ' must be an https URL.')
	}
	if (parsed.protocol !== 'https:') {
		throw new Error(key + ' must be an https URL.')
	}
	if (parsed.username || parsed.password) {
		throw new Error(key + ' must not include credentials.')
	}
	return parsed.origin + parsed.pathname.replace(/\/$/, '')
}

export function hostFromHttpsUrl(value: string): string {
	return new URL(value).host
}

export function asRecord(value: unknown): Record<string, unknown> {
	if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
	return value as Record<string, unknown>
}

export function stringify(value: unknown): string {
	try {
		return JSON.stringify(value, null, 2)
	} catch {
		return String(value)
	}
}

export function clean(value: unknown): string {
	return String(value ?? '').trim()
}

export function assertNever(value: never, label: string): never {
	throw new Error('Unhandled ' + label + ': ' + String(value))
}