/** * @fileoverview Provider-agnostic chat completions for @kody/ai (`generateText`, `listModels`, resolve helpers). * Pass `dryRun: true` to preview without calling a provider. * * @example * import complete from 'kody:@kody/ai/complete' * const preview = await complete({ provider: 'openai', messages: [{ role: 'user', content: 'Hello' }], dryRun: true }) */ import { chatUrl, defaultSecretName, modelsUrl, providerCatalog, providerSecretUrl, requestHeaders, } from './providers.ts' import { getSettings } from './settings.ts' import { asRecord, assertHttpsUrl, boundedMaxTokens, inputRecord, isPreviewDryRun, optionalString, parseProviderId, stringify, type ProviderId, } from './validation.ts' export type ChatMessage = { role: 'system' | 'user' | 'assistant' | 'tool' content?: string | null tool_calls?: unknown[] tool_call_id?: string name?: string } export type CompleteInput = { messages?: Array prompt?: string system?: string provider?: ProviderId | string model?: string modelId?: string baseUrl?: string cloudflareAccountId?: string cloudflareAiGatewayId?: string account?: string apiKeySecret?: string maxTokens?: number temperature?: number tools?: unknown[] toolChoice?: unknown responseFormat?: unknown dryRun?: boolean } export type ToolCall = { toolCallId: string toolName: string input: Record } export type CompleteResult = { ok: boolean error?: string text: string reasoningText: string finishReason: string toolCalls: Array provider: ProviderId model: string host: string } export type CompletePreview = { dryRun: true provider: ProviderId model: string host: string href: string apiKeySecret: string secretUrl: string maxTokens: number messageCount: number body: Record } export type ResolvedComplete = { provider: ProviderId model: string href: string host: string apiKeySecret: string headers: Record maxTokens: number body: Record messages: Array } function parseArguments(value: unknown): Record { if (!value) return {} if (typeof value === 'object' && !Array.isArray(value)) { return value as Record } if (typeof value !== 'string') return {} try { const parsed = JSON.parse(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } catch { return {} } } function normalizeMessages(input: CompleteInput): Array { const messages: Array = [] if (input.system) messages.push({ role: 'system', content: input.system }) if (Array.isArray(input.messages)) { for (const message of input.messages) { messages.push({ role: message.role || 'user', content: message.content ?? '', tool_calls: message.tool_calls, tool_call_id: message.tool_call_id, name: message.name, }) } } if (input.prompt) messages.push({ role: 'user', content: input.prompt }) return messages } function openaiBody( model: string, messages: Array, maxTokens: number, input: CompleteInput, ): Record { const body: Record = { model, messages, max_tokens: maxTokens, } if (typeof input.temperature === 'number') body.temperature = input.temperature if (Array.isArray(input.tools) && input.tools.length > 0) { body.tools = input.tools body.tool_choice = input.toolChoice ?? 'auto' } if (input.responseFormat) body.response_format = input.responseFormat return body } function anthropicBody( model: string, messages: Array, maxTokens: number, input: CompleteInput, ): Record { const system = messages .filter((message) => message.role === 'system') .map((message) => message.content ?? '') .filter(Boolean) .join('\n\n') const converted = messages .filter((message) => message.role !== 'system') .map((message) => { if (message.role === 'tool') { return { role: 'user', content: [ { type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content ?? '', }, ], } } if (message.role === 'assistant' && message.tool_calls) { const text = message.content ? [{ type: 'text', text: message.content }] : [] const tools = (message.tool_calls as Array>).map( (call) => { const fn = asRecord(call.function) return { type: 'tool_use', id: call.id, name: fn.name ?? call.name, input: parseArguments(fn.arguments ?? call.arguments), } }, ) return { role: 'assistant', content: [...text, ...tools] } } return { role: message.role, content: message.content ?? '' } }) const body: Record = { model, max_tokens: maxTokens, messages: converted, } if (system) body.system = system if (typeof input.temperature === 'number') body.temperature = input.temperature if (Array.isArray(input.tools) && input.tools.length > 0) { body.tools = input.tools.map((tool) => { const record = asRecord(tool) const fn = asRecord(record.function) if (record.type === 'function' || fn.name) { return { name: fn.name, description: fn.description, input_schema: fn.parameters ?? { type: 'object', properties: {} }, } } return tool }) body.tool_choice = input.toolChoice ?? { type: 'auto' } } return body } /** * One provider-agnostic chat completion. Pass `dryRun: true` to preview. * Default export of `./complete` (same as `generateText`). * * @param input - Completion options (`provider`, `messages`/`prompt`, `model`, `dryRun`, …). * @returns Completion result or dry-run preview. * * @example * import complete from 'kody:@kody/ai/complete' * const preview = await complete({ * provider: 'openai', * messages: [{ role: 'user', content: 'Hello' }], * dryRun: true, * }) */ export default async function complete(input: CompleteInput = {}): Promise { return await generateText(input) } export async function resolveComplete( input: CompleteInput = {}, ): Promise { const parsed = inputRecord(input ?? {}) const settings = await getSettings() const provider = parseProviderId(optionalString(parsed, 'provider')) ?? settings.provider ?? 'openai' const catalog = providerCatalog(provider) const model = optionalString(parsed, 'model') ?? optionalString(parsed, 'modelId') ?? settings.model ?? catalog.defaultModel if (!model) { throw new Error( 'model is required. Pass model or store a default in ./settings on your fork.', ) } const baseUrlRaw = optionalString(parsed, 'baseUrl') ?? settings.baseUrl ?? undefined const baseUrl = baseUrlRaw ? assertHttpsUrl(baseUrlRaw, 'baseUrl') : undefined const cloudflareAccountId = optionalString(parsed, 'cloudflareAccountId') ?? settings.cloudflareAccountId ?? undefined const cloudflareAiGatewayId = optionalString(parsed, 'cloudflareAiGatewayId') ?? settings.cloudflareAiGatewayId ?? undefined const apiKeySecret = defaultSecretName( provider, optionalString(parsed, 'account'), optionalString(parsed, 'apiKeySecret') ?? settings.apiKeySecret ?? undefined, ) const messages = normalizeMessages(input) const maxTokens = boundedMaxTokens(parsed.maxTokens) const endpoint = chatUrl({ provider, baseUrl, cloudflareAccountId, cloudflareAiGatewayId, }) const body = provider === 'anthropic' ? anthropicBody(model, messages, maxTokens, input) : openaiBody(model, messages, maxTokens, input) return { provider, model, href: endpoint.href, host: endpoint.host, apiKeySecret, headers: requestHeaders(provider, apiKeySecret), maxTokens, body, messages, } } function choice(payload: unknown) { const choices = asRecord(payload).choices return Array.isArray(choices) && choices[0] && typeof choices[0] === 'object' ? (choices[0] as Record) : null } function openaiMessage(payload: unknown) { const msg = choice(payload)?.message return msg && typeof msg === 'object' ? (msg as Record) : null } function textFromOpenAI(payload: unknown) { const msg = openaiMessage(payload) if (typeof msg?.content === 'string') return msg.content return '' } function reasoningFromOpenAI(payload: unknown) { const msg = openaiMessage(payload) if (typeof msg?.reasoning_content === 'string') return msg.reasoning_content if (typeof msg?.reasoning === 'string') return msg.reasoning return '' } function finishFromOpenAI(payload: unknown) { const reason = choice(payload)?.finish_reason return typeof reason === 'string' ? reason : 'stop' } function toolCallsFromOpenAI(payload: unknown): Array { const msg = openaiMessage(payload) const calls = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [] return calls.map((call, index) => { const record = asRecord(call) const fn = asRecord(record.function) return { toolCallId: typeof record.id === 'string' && record.id ? record.id : 'tool_' + index, toolName: typeof fn.name === 'string' ? fn.name : String(record.name ?? ''), input: parseArguments(fn.arguments ?? record.arguments), } }) } function textFromAnthropic(payload: unknown) { const content = asRecord(payload).content if (!Array.isArray(content)) { return typeof asRecord(payload).content === 'string' ? String(asRecord(payload).content) : '' } return content .map((block) => { const record = asRecord(block) return record.type === 'text' && typeof record.text === 'string' ? record.text : '' }) .filter(Boolean) .join('\n') } function toolCallsFromAnthropic(payload: unknown): Array { const content = asRecord(payload).content if (!Array.isArray(content)) return [] const calls: Array = [] for (const block of content) { const record = asRecord(block) if (record.type !== 'tool_use') continue calls.push({ toolCallId: typeof record.id === 'string' ? record.id : 'tool_' + calls.length, toolName: typeof record.name === 'string' ? record.name : '', input: asRecord(record.input), }) } return calls } function normalizePayload( provider: ProviderId, payload: unknown, ): Pick { if (provider === 'anthropic') { const stop = asRecord(payload).stop_reason return { text: textFromAnthropic(payload), reasoningText: '', finishReason: typeof stop === 'string' ? stop : 'stop', toolCalls: toolCallsFromAnthropic(payload), } } return { text: textFromOpenAI(payload), reasoningText: reasoningFromOpenAI(payload), finishReason: finishFromOpenAI(payload), toolCalls: toolCallsFromOpenAI(payload), } } export function completePreview(resolved: ResolvedComplete): CompletePreview { return { dryRun: true, provider: resolved.provider, model: resolved.model, host: resolved.host, href: resolved.href, apiKeySecret: resolved.apiKeySecret, secretUrl: providerSecretUrl( resolved.provider, resolved.apiKeySecret, resolved.host, ), maxTokens: resolved.maxTokens, messageCount: resolved.messages.length, body: resolved.body, } } async function postComplete(resolved: ResolvedComplete): Promise { const response = await fetch(resolved.href, { method: 'POST', headers: resolved.headers, body: JSON.stringify(resolved.body), }) const raw = await response.text() let payload: unknown = raw try { payload = raw ? JSON.parse(raw) : null } catch { payload = raw } if (!response.ok) { throw new Error( resolved.provider + ' chat request failed: ' + response.status + ' ' + stringify(payload) + '. Save ' + resolved.apiKeySecret + ' and approve host ' + resolved.host + ': ' + providerSecretUrl(resolved.provider, resolved.apiKeySecret, resolved.host), ) } const normalized = normalizePayload(resolved.provider, payload) return { ok: true, ...normalized, provider: resolved.provider, model: resolved.model, host: resolved.host, } } /** * One provider-agnostic chat completion. Pass `dryRun: true` to preview. * * @param input - Completion options (`provider`, `messages`/`prompt`, `model`, `dryRun`, …). * @returns Completion result or dry-run preview. * * @example * import { generateText } from 'kody:@kody/ai/complete' * const preview = await generateText({ * provider: 'openai', * messages: [{ role: 'user', content: 'Hello' }], * dryRun: true, * }) */ export async function generateText( input: CompleteInput = {}, ): Promise { const resolved = await resolveComplete(input) if (resolved.messages.length === 0) { throw new Error('messages or prompt is required.') } if (isPreviewDryRun(input)) return completePreview(resolved) try { return await postComplete(resolved) } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), text: '', reasoningText: '', finishReason: 'error', toolCalls: [], provider: resolved.provider, model: resolved.model, host: resolved.host, } } } export async function resolveProvider(input: CompleteInput = {}) { const parsed = inputRecord(input ?? {}) const settings = await getSettings() const provider = parseProviderId(optionalString(parsed, 'provider')) ?? settings.provider ?? 'openai' const catalog = providerCatalog(provider) const model = optionalString(parsed, 'model') ?? optionalString(parsed, 'modelId') ?? settings.model ?? catalog.defaultModel const baseUrlRaw = optionalString(parsed, 'baseUrl') ?? settings.baseUrl ?? undefined const baseUrl = baseUrlRaw ? assertHttpsUrl(baseUrlRaw, 'baseUrl') : undefined const apiKeySecret = defaultSecretName( provider, optionalString(parsed, 'account'), optionalString(parsed, 'apiKeySecret') ?? settings.apiKeySecret ?? undefined, ) return { provider, model, baseUrl, apiKeySecret, cloudflareAccountId: optionalString(parsed, 'cloudflareAccountId') ?? settings.cloudflareAccountId ?? undefined, } } export async function listModels(input: CompleteInput = {}) { const resolved = await resolveProvider(input) const models = modelsUrl({ provider: resolved.provider, baseUrl: resolved.baseUrl, cloudflareAccountId: resolved.cloudflareAccountId, }) if (!models) { return { ok: false, error: 'Cannot list models for this provider until baseUrl or cloudflareAccountId is set on your fork.', provider: resolved.provider, } } if (isPreviewDryRun(input)) { return { dryRun: true as const, provider: resolved.provider, href: models.href, host: models.host, apiKeySecret: resolved.apiKeySecret, } } const response = await fetch(models.href, { method: 'GET', headers: requestHeaders(resolved.provider, resolved.apiKeySecret), }) const raw = await response.text() let payload: unknown = raw try { payload = raw ? JSON.parse(raw) : null } catch { payload = raw } if (!response.ok) { throw new Error( 'Models list failed: ' + response.status + ' ' + stringify(payload) + '. ' + providerSecretUrl(resolved.provider, resolved.apiKeySecret, models.host), ) } const record = asRecord(payload) const data = Array.isArray(record.data) ? record.data : Array.isArray(record.result) ? record.result : [] const ids = data .slice(0, 8) .map((item) => { const row = asRecord(item) return typeof row.id === 'string' ? row.id : null }) .filter((id): id is string => Boolean(id)) return { ok: true, provider: resolved.provider, host: models.host, modelCount: data.length, sampleIds: ids, } }