/** * {{APP_NAME}} — server component for the AI template. * * Thin proxy over Kazzle's AI gateway. Demonstrates the full modality * surface customers can build on top of one `kzl_…` API key: * * POST /chat → /ai/chat/completions (OpenAI-shape, including structured output) * POST /image → /ai/images/generations (standardized) * POST /speech → /ai/audio/speech (binary audio) * POST /transcribe → /ai/audio/transcriptions (multipart in) * POST /video → /ai/video/generations (async job) * GET /video/:id → /ai/responses/:id (poll) * GET /health → local server/proxy health (no upstream call) * * The browser only ever talks to this server, never to Kazzle directly — * that's how the `kzl_…` key stays server-side and how customers add * their own auth/rate-limits/business rules around AI usage. * * Env: * - `KAZZLE_API_KEY` required. * - `KAZZLE_API_URL` required API root, for example `https://api.kazzle.app`. * - `KAZZLE_AI_MODEL` default `openai/gpt-5.5`. * - `KAZZLE_IMAGE_MODEL` default `google/gemini-2.5-flash-image`. * - `KAZZLE_SPEECH_MODEL` default `openai/gpt-audio`. * - `KAZZLE_TRANSCRIPTION_MODEL` default `google/gemini-3.5-flash`. * - `KAZZLE_VIDEO_MODEL` default `bytedance/seedance-2.0-mini`. * * Override any model via the request body (`{ model: '…' }`); the env * default is just a sensible fallback. */ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import type { Context } from 'hono'; import { cors } from 'hono/cors'; const apiKey = process.env.KAZZLE_API_KEY; if (!apiKey) throw new Error('KAZZLE_API_KEY is required (mint with `api_key { create: {} }`)'); const apiUrl = process.env.KAZZLE_API_URL; if (!apiUrl) throw new Error('KAZZLE_API_URL is required and must point at the matching Kazzle API environment'); const baseUrl = `${apiUrl.replace(/\/$/, '')}/ai`; const defaults = { chat: process.env.KAZZLE_AI_MODEL || 'openai/gpt-5.5', image: process.env.KAZZLE_IMAGE_MODEL || 'google/gemini-2.5-flash-image', speech: process.env.KAZZLE_SPEECH_MODEL || 'openai/gpt-audio', transcription: process.env.KAZZLE_TRANSCRIPTION_MODEL || 'google/gemini-3.5-flash', video: process.env.KAZZLE_VIDEO_MODEL || 'bytedance/seedance-2.0-mini', }; type ChatMessage = { role: 'system' | 'user' | 'assistant'; content: string }; type ChatCompletionPayload = { choices?: Array<{ message?: ChatMessage }>; model?: string }; type JsonSchemaResponseFormat = { type: 'json_schema'; json_schema?: unknown }; type ChatResponseBody = { message: ChatMessage; model: string; structured?: unknown }; const authHeader = `Bearer ${apiKey}`; const app = new Hono(); app.use('*', cors()); app.get('/health', c => c.json({ ok: true, defaults })); // ── Chat ───────────────────────────────────────────────────────────── app.post('/chat', async c => { const body = await readJsonBody(c); if (!body) return c.json({ error: 'Invalid JSON body' }, 400); const messages = Array.isArray(body.messages) ? body.messages : null; if (!messages || messages.length === 0) { return c.json({ error: '`messages` must be a non-empty array' }, 400); } for (const m of messages as Partial[]) { if (m.role !== 'system' && m.role !== 'user' && m.role !== 'assistant') { return c.json({ error: 'Each message needs role of system|user|assistant' }, 400); } if (typeof m.content !== 'string') { return c.json({ error: 'Each message needs string content' }, 400); } } const { model, stream, messages: _m, ...rest } = body; const upstream = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader }, body: JSON.stringify({ model: model || defaults.chat, messages, ...rest, stream: false }), }); if (!upstream.ok) return c.json({ error: `Upstream ${upstream.status}: ${await upstream.text()}` }, 502); const data = await upstream.json() as ChatCompletionPayload; const assistant = data.choices?.[0]?.message; if (!assistant?.content) return c.json({ error: 'Upstream returned no assistant message' }, 502); const response: ChatResponseBody = { message: assistant, model: data.model ?? (typeof model === 'string' ? model : defaults.chat), }; if (isJsonSchemaResponseFormat(body.response_format)) { const parsed = parseStructuredContent(assistant.content); if (!parsed.ok) return c.json({ error: parsed.error }, 502); response.structured = parsed.value; } return c.json(response); }); // ── Image ──────────────────────────────────────────────────────────── // Standardized output: `{ images: [{ url? b64? mimeType }] }`. The proxy // hands us this shape regardless of provider so the UI can render either // a data URL (from b64) or a remote URL without provider branching. app.post('/image', async c => { const body = await readJsonBody(c); if (!body || typeof body.prompt !== 'string' || !body.prompt.trim()) { return c.json({ error: '`prompt` is required' }, 400); } const { model, prompt, ...rest } = body; const upstream = await fetch(`${baseUrl}/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader }, body: JSON.stringify({ model: model || defaults.image, prompt, ...rest }), }); return passthroughJson(upstream); }); // ── Speech (TTS, binary) ───────────────────────────────────────────── // Returns raw audio bytes. The Content-Type tells the browser/client // which decoder to use; the UI just hands it to