import "server-only"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { getDb, conversations, messages, messageAttachments, usageRecords, arenaResponses, arenaSessions, type Conversation, type Message } from "@/db"; import { ids } from "@/lib/ids"; import { log } from "@/lib/log"; import { ApiError } from "@/lib/api"; import { getModel, touchRecent } from "@/lib/ai/registry"; import { getAdapter } from "@/lib/ai/providers"; import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys"; import { isCustomModelKey, resolveCustomEndpoint } from "@/lib/endpoints/service"; import type { AIProviderAdapter } from "@/lib/ai/core/types"; import { estimateCost } from "@/lib/ai/core/pricing"; import { StreamAccumulator } from "@/lib/ai/core/stream-utils"; import { filterSettings } from "@/lib/ai/core/normalize"; import { ERROR_MESSAGES } from "@/lib/ai/core/errors"; import { textOf } from "@/lib/ai/core/content"; import type { ContentPart, PolyModel, UnifiedGenerationSettings, UnifiedMessage, UnifiedStreamEvent, Usage, PolyProviderErrorShape } from "@/lib/ai/core/types"; import { resolveTools, runBuiltinTool } from "./tools"; import type { ChatAdoptInput, ChatRequestInput, GenerationSettingsInput } from "./schemas"; export type StoredPart = | { type: "text"; text: string } | { type: "reasoning"; text: string; signature?: string; providerData?: unknown; durationMs?: number } | { type: "attachment"; attachmentId: string; kind: string; name: string; mimeType: string; sizeBytes: number; width?: number | null; height?: number | null } | { type: "tool-call"; id: string; name: string; arguments: Record; argumentsText?: string; providerData?: unknown; result?: unknown; isError?: boolean; durationMs?: number } | { type: "server-tool"; name: string; status: string; data?: unknown } | { type: "citation"; url?: string; title?: string; snippet?: string } | { type: "refusal"; category?: string | null; explanation?: string | null }; const MAX_TOOL_ROUNDS = 5; // --------------------------------------------------------------------------- // Loading // --------------------------------------------------------------------------- export async function loadConversation(userId: string, id: string): Promise { const [c] = await getDb() .select() .from(conversations) .where(and(eq(conversations.id, id), eq(conversations.userId, userId))) .limit(1); if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND"); return c; } export async function loadActiveMessages(conversationId: string): Promise { return getDb() .select() .from(messages) .where(and(eq(messages.conversationId, conversationId), eq(messages.active, true))) .orderBy(asc(messages.createdAt)); } async function loadAttachments(userId: string, attachmentIds: string[]) { if (!attachmentIds.length) return []; return getDb() .select() .from(messageAttachments) .where(and(eq(messageAttachments.userId, userId), inArray(messageAttachments.id, attachmentIds))); } /** Convert stored messages (with attachment references) into provider-neutral messages. */ export async function toUnifiedMessages(userId: string, rows: Message[], model: PolyModel): Promise { const attIds = rows.flatMap((r) => (r.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId)); const atts = await loadAttachments(userId, attIds); const byId = new Map(atts.map((a) => [a.id, a])); const out: UnifiedMessage[] = []; for (const r of rows) { if (r.status === "error" && r.role === "assistant" && !r.content) continue; const parts: ContentPart[] = []; const toolResults: ContentPart[] = []; for (const p of r.parts as StoredPart[]) { switch (p.type) { case "text": if (p.text) parts.push({ type: "text", text: p.text }); break; case "reasoning": if (p.text) parts.push({ type: "reasoning", text: p.text, signature: p.signature, providerData: p.providerData }); break; case "attachment": { const a = byId.get(p.attachmentId); if (!a) break; if (a.kind === "image" && model.capabilities.vision) parts.push({ type: "image", mimeType: a.mimeType, data: a.dataBase64, name: a.name }); else if (a.kind !== "image") parts.push({ type: "file", mimeType: a.mimeType, data: a.dataBase64, name: a.name }); else parts.push({ type: "text", text: `[Image "${a.name}" omitted — this model has no vision]` }); break; } case "tool-call": parts.push({ type: "tool-call", id: p.id, name: p.name, arguments: p.arguments, argumentsText: p.argumentsText, providerData: p.providerData }); if (p.result !== undefined) toolResults.push({ type: "tool-result", toolCallId: p.id, name: p.name, result: p.result, isError: p.isError }); break; default: break; } } if (!parts.length) continue; out.push({ role: r.role as UnifiedMessage["role"], content: parts, providerData: r.modelKey ? { model: r.modelKey.split("/").slice(1).join("/") } : undefined }); if (toolResults.length) out.push({ role: "tool", content: toolResults }); } return out; } // --------------------------------------------------------------------------- // Orchestration // --------------------------------------------------------------------------- export interface ChatContext { userId: string; requestId: string; ip: string; } export interface PreparedTurn { conversation: Conversation; model: PolyModel; apiKey: string; /** Custom endpoint adapter; undefined = registry provider adapter. */ adapter?: AIProviderAdapter; history: UnifiedMessage[]; userMessage: Message | null; assistantMessage: Message; settings: UnifiedGenerationSettings; toolIds: string[]; systemPrompt: string | undefined; isNewConversation: boolean; continuation: boolean; /** Temporary chat: nothing but the usage record is persisted. */ ephemeral: boolean; } /** Sentinel conversation id used for temporary chats (never written to the database). */ export const EPHEMERAL_CONVERSATION_ID = "ephemeral"; function syntheticMessage(userId: string, conversationId: string, role: "user" | "assistant", content: string, parts: StoredPart[], extra: Partial = {}): Message { const now = new Date(); return { id: ids.message(), conversationId, userId, role, content, parts, modelKey: null, provider: null, status: "complete", finishReason: null, error: null, usage: null, settings: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: now, updatedAt: now, ...extra, } as Message; } function toUnifiedSettings(s: GenerationSettingsInput | undefined): { settings: UnifiedGenerationSettings; toolIds: string[] } { if (!s) return { settings: {}, toolIds: [] }; const { tools, ...rest } = s; return { settings: rest as UnifiedGenerationSettings, toolIds: tools ?? [] }; } export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Promise { const db = getDb(); // Custom OpenAI-compatible endpoints (Settings → Endpoints) resolve to their own adapter + key. const custom = isCustomModelKey(input.modelKey) ? await resolveCustomEndpoint(ctx.userId, input.modelKey) : null; const model = custom?.model ?? (await getModel(input.modelKey)); if (!model) throw new ApiError(404, isCustomModelKey(input.modelKey) ? "This custom endpoint no longer exists. Check Settings → Endpoints." : "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND"); const apiKey = custom?.apiKey ?? (await getDecryptedKey(ctx.userId, model.provider)); if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. Add one in Settings → Providers.`, "NO_PROVIDER_KEY", { provider: model.provider }); const adapter = custom?.adapter; const { settings: rawSettings, toolIds } = toUnifiedSettings(input.settings); const { settings } = filterSettings(rawSettings, model); const now = new Date(); // --- temporary chat: nothing touches conversations/messages --------------- if (input.ephemeral) { if (input.conversationId || input.action !== "send") throw new ApiError(400, "Temporary chats only accept new messages", "BAD_REQUEST"); if (!input.message || (!input.message.text.trim() && !input.message.attachmentIds?.length)) throw new ApiError(400, "Message is empty", "EMPTY_MESSAGE"); const conversation = { id: EPHEMERAL_CONVERSATION_ID, userId: ctx.userId, title: "Temporary chat", titleSource: "auto", folderId: null, projectId: null, pinned: false, archived: false, modelKey: model.key, provider: model.provider, systemPrompt: input.systemPrompt ?? null, settings: { ...(input.settings ?? {}) }, parentConversationId: null, branchedFromMessageId: null, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, lastMessageAt: null, createdAt: now, updatedAt: now, } as Conversation; const rows: Message[] = (input.history ?? []).filter((h) => h.content.trim()).map((h) => syntheticMessage(ctx.userId, conversation.id, h.role, h.content, [{ type: "text", text: h.content }], h.role === "assistant" ? { modelKey: model.key, provider: model.provider } : {})); const atts = await loadAttachments(ctx.userId, input.message.attachmentIds ?? []); const parts: StoredPart[] = []; if (input.message.text.trim()) parts.push({ type: "text", text: input.message.text }); for (const a of atts) parts.push({ type: "attachment", attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }); const userMessage = syntheticMessage(ctx.userId, conversation.id, "user", input.message.text, parts); const assistantMessage = syntheticMessage(ctx.userId, conversation.id, "assistant", "", [], { status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null }); const history = await toUnifiedMessages(ctx.userId, [...rows, userMessage], model); return { adapter, conversation, model, apiKey, history, userMessage, assistantMessage, settings, toolIds, systemPrompt: input.systemPrompt ?? undefined, isNewConversation: false, continuation: false, ephemeral: true }; } // --- conversation -------------------------------------------------------- let conversation: Conversation; let isNew = false; if (input.conversationId) { conversation = await loadConversation(ctx.userId, input.conversationId); await db .update(conversations) .set({ modelKey: model.key, provider: model.provider, settings: { ...(input.settings ?? {}) }, systemPrompt: input.systemPrompt === undefined ? conversation.systemPrompt : input.systemPrompt, updatedAt: now }) .where(eq(conversations.id, conversation.id)); } else { if (input.action !== "send") throw new ApiError(400, "A conversation is required for this action", "BAD_REQUEST"); isNew = true; const [created] = await db .insert(conversations) .values({ id: ids.conversation(), userId: ctx.userId, title: "New chat", modelKey: model.key, provider: model.provider, systemPrompt: input.systemPrompt ?? null, settings: { ...(input.settings ?? {}) }, projectId: input.projectId ?? null }) .returning(); conversation = created; } const systemPrompt = (input.systemPrompt === undefined ? conversation.systemPrompt : input.systemPrompt) ?? undefined; // --- history + new message ------------------------------------------------ let rows = await loadActiveMessages(conversation.id); let userMessage: Message | null = null; let continuation = false; if (input.action === "send") { if (!input.message || (!input.message.text.trim() && !input.message.attachmentIds?.length)) throw new ApiError(400, "Message is empty", "EMPTY_MESSAGE"); const atts = await loadAttachments(ctx.userId, input.message.attachmentIds ?? []); const parts: StoredPart[] = []; if (input.message.text.trim()) parts.push({ type: "text", text: input.message.text }); for (const a of atts) parts.push({ type: "attachment", attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }); const [um] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "user", content: input.message.text, parts, status: "complete" }) .returning(); userMessage = um; if (atts.length) await db.update(messageAttachments).set({ messageId: um.id, conversationId: conversation.id }).where(inArray(messageAttachments.id, atts.map((a) => a.id))); rows = [...rows, um]; } else if (input.action === "edit") { if (!input.targetMessageId || !input.message) throw new ApiError(400, "targetMessageId and message are required", "BAD_REQUEST"); const target = rows.find((r) => r.id === input.targetMessageId && r.role === "user"); if (!target) throw new ApiError(404, "Message not found", "NOT_FOUND"); const idx = rows.indexOf(target); // deactivate the edited message and everything after it (they belong to the old branch) const toDeactivate = rows.slice(idx).map((r) => r.id); await db.update(messages).set({ active: false, updatedAt: now }).where(inArray(messages.id, toDeactivate)); const keptParts = (target.parts as StoredPart[]).filter((p) => p.type === "attachment"); const parts: StoredPart[] = [{ type: "text", text: input.message.text }, ...keptParts]; const [um] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "user", content: input.message.text, parts, status: "complete", parentMessageId: target.parentMessageId ?? target.id, version: target.version + 1 }) .returning(); userMessage = um; rows = [...rows.slice(0, idx), um]; } else if (input.action === "regenerate" || input.action === "retry") { if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST"); const target = rows.find((r) => r.id === input.targetMessageId && r.role === "assistant"); if (!target) throw new ApiError(404, "Message not found", "NOT_FOUND"); const idx = rows.indexOf(target); const toDeactivate = rows.slice(idx).map((r) => r.id); await db.update(messages).set({ active: false, updatedAt: now }).where(inArray(messages.id, toDeactivate)); rows = rows.slice(0, idx); // create the new assistant version below const [am] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null, parentMessageId: target.parentMessageId ?? target.id, version: target.version + 1 }) .returning(); const history = await toUnifiedMessages(ctx.userId, rows, model); return { adapter, conversation, model, apiKey, history, userMessage: null, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: false, continuation: false, ephemeral: false }; } else if (input.action === "continue") { if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST"); const target = rows[rows.length - 1]; if (!target || target.id !== input.targetMessageId || target.role !== "assistant") throw new ApiError(400, "Only the last assistant message can be continued", "BAD_REQUEST"); continuation = true; await db.update(messages).set({ status: "streaming", updatedAt: now }).where(eq(messages.id, target.id)); const history = await toUnifiedMessages(ctx.userId, rows, model); history.push({ role: "user", content: [{ type: "text", text: "Continue exactly where you left off, without repeating anything you already wrote." }] }); return { adapter, conversation, model, apiKey, history, userMessage: null, assistantMessage: target, settings, toolIds, systemPrompt, isNewConversation: false, continuation, ephemeral: false }; } const [am] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null }) .returning(); const history = await toUnifiedMessages(ctx.userId, rows, model); return { adapter, conversation, model, apiKey, history, userMessage, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: isNew, continuation, ephemeral: false }; } export interface TurnOutcome { message: Message; usage?: Usage; costUsd: number | null; latencyMs: number; ttftMs?: number; status: "complete" | "stopped" | "error"; error?: PolyProviderErrorShape; title?: string; } /** * Runs the provider stream (with built-in tool loop), emitting unified events to `emit` * and persisting the assistant message at the end. Never throws for provider errors — * they are emitted and stored on the message. */ export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: unknown) => void, signal: AbortSignal): Promise { const db = getDb(); const adapter = turn.adapter ?? getAdapter(turn.model.provider); const tools = resolveTools(turn.toolIds).filter(() => turn.model.capabilities.tools); const t0 = Date.now(); const previousParts = turn.continuation ? (turn.assistantMessage.parts as StoredPart[]) : []; const previousText = turn.continuation ? turn.assistantMessage.content : ""; let history = [...turn.history]; const storedParts: StoredPart[] = [...previousParts]; let fullText = previousText; let reasoningStart: number | undefined; let usageTotal: Usage | undefined; let ttft: number | undefined; let finish: string | undefined; let error: PolyProviderErrorShape | undefined; let stopped = false; const providerData: Record = {}; const addUsage = (u: Usage) => { usageTotal = usageTotal ? { inputTokens: usageTotal.inputTokens + u.inputTokens, outputTokens: usageTotal.outputTokens + u.outputTokens, cachedInputTokens: (usageTotal.cachedInputTokens ?? 0) + (u.cachedInputTokens ?? 0), reasoningTokens: (usageTotal.reasoningTokens ?? 0) + (u.reasoningTokens ?? 0), totalTokens: (usageTotal.totalTokens ?? 0) + (u.totalTokens ?? 0), } : { ...u }; }; for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) { const acc = new StreamAccumulator(); let roundText = ""; let roundReasoning = ""; let roundSignature: string | undefined; let roundReasoningData: unknown; const stream = adapter.streamChat({ provider: turn.model.provider, model: turn.model.id, apiKey: turn.apiKey, system: turn.systemPrompt, messages: history, settings: turn.settings, tools: tools.length ? tools.map((t) => t.definition) : undefined, modelInfo: turn.model, signal, requestId: ctx.requestId, }); try { for await (const ev of stream as AsyncIterable) { if (signal.aborted) { stopped = true; break; } acc.push(ev); switch (ev.type) { case "text-delta": if (ttft === undefined) ttft = Date.now() - t0; roundText += ev.text; fullText += ev.text; emit(ev); break; case "reasoning-delta": if (ttft === undefined) ttft = Date.now() - t0; if (reasoningStart === undefined) reasoningStart = Date.now(); roundReasoning += ev.text; emit(ev); break; case "reasoning-signature": roundSignature = ev.signature; roundReasoningData = ev.providerData; break; case "usage": addUsage(ev.usage); break; case "provider-data": Object.assign(providerData, ev.data); if (ev.data.refusal) { storedParts.push({ type: "refusal", ...(ev.data.refusal as { category?: string | null; explanation?: string | null }) }); emit({ type: "refusal", ...(ev.data.refusal as object) }); } break; case "citation": storedParts.push({ type: "citation", url: ev.citation.url, title: ev.citation.title, snippet: ev.citation.snippet }); emit(ev); break; case "server-tool": storedParts.push({ type: "server-tool", name: ev.name, status: ev.status, data: ev.data }); emit(ev); break; case "tool-start": case "tool-delta": emit(ev); break; case "tool-end": // handled after the stream via acc.toolCallParts() break; case "finish": finish = ev.reason; break; case "error": error = ev.error; break; default: break; } } } catch (e) { error = adapter.normalizeError(e).toJSON(); } // persist this round's reasoning + text if (roundReasoning) storedParts.push({ type: "reasoning", text: roundReasoning, signature: roundSignature, providerData: roundReasoningData, durationMs: reasoningStart ? Date.now() - reasoningStart : undefined }); if (roundText) storedParts.push({ type: "text", text: roundText }); if (error || stopped) break; const calls = acc.toolCallParts().filter((c) => c.name); if (!calls.length || acc.finishReason !== "tool-calls" || round === MAX_TOOL_ROUNDS) break; // --- execute built-in tools and loop ------------------------------------- const assistantParts: ContentPart[] = []; if (roundReasoning && roundSignature) assistantParts.push({ type: "reasoning", text: roundReasoning, signature: roundSignature, providerData: roundReasoningData }); if (roundText) assistantParts.push({ type: "text", text: roundText }); assistantParts.push(...calls); const results: ContentPart[] = []; for (const call of calls) { emit({ type: "tool-end", id: call.id, name: call.name, arguments: call.arguments, argumentsText: call.argumentsText }); const r = await runBuiltinTool(call.name, call.arguments); storedParts.push({ type: "tool-call", id: call.id, name: call.name, arguments: call.arguments, argumentsText: call.argumentsText, providerData: call.providerData, result: r.result, isError: r.isError, durationMs: r.durationMs }); emit({ type: "tool-result", id: call.id, name: call.name, result: r.result, isError: r.isError, durationMs: r.durationMs }); results.push({ type: "tool-result", toolCallId: call.id, name: call.name, result: r.result, isError: r.isError }); } history = [...history, { role: "assistant", content: assistantParts, providerData: { model: turn.model.id } }, { role: "tool", content: results }]; } // --- finalize --------------------------------------------------------------- if (turn.settings.responseFormat && turn.settings.responseFormat.type !== "text") { const unfenced = stripJsonFence(fullText); if (unfenced !== fullText) { fullText = unfenced; for (let i = storedParts.length - 1; i >= 0; i--) { const part = storedParts[i]; if (part.type === "text") { part.text = stripJsonFence(part.text); break; } } } } const latencyMs = Date.now() - t0; const cost = estimateCost(usageTotal, turn.model.pricing); const exactCost = typeof providerData.exactCostUsd === "number" ? (providerData.exactCostUsd as number) : null; const costUsd = exactCost ?? (cost.known ? cost.totalUsd : null); const status: TurnOutcome["status"] = error ? (fullText ? "stopped" : "error") : stopped ? "stopped" : "complete"; const now = new Date(); // Stored error keeps the safe diagnostic fields (provider code, HTTP status, retry hint) so the UI can explain it. const storedError = error ? toStoredError(error) : null; const finishReason = finish ?? (stopped ? "cancelled" : error ? "error" : null); const usageJson = usageTotal ? (usageTotal as unknown as Record) : null; if (turn.ephemeral) { // Temporary chat: no conversation/message rows. Only the usage record is written (conversation null). const saved: Message = { ...turn.assistantMessage, content: fullText, parts: storedParts, status, finishReason, error: storedError, usage: usageJson, latencyMs, ttftMs: ttft ?? null, costUsd, updatedAt: now }; await db.insert(usageRecords).values({ id: ids.usage(), userId: ctx.userId, conversationId: null, messageId: null, provider: turn.model.provider, modelKey: turn.model.key, kind: "chat", status: error ? "error" : stopped ? "stopped" : "ok", errorCode: error?.code ?? null, inputTokens: usageTotal?.inputTokens ?? 0, outputTokens: usageTotal?.outputTokens ?? 0, cachedTokens: usageTotal?.cachedInputTokens ?? 0, reasoningTokens: usageTotal?.reasoningTokens ?? 0, costUsd, latencyMs, ttftMs: ttft ?? null, }); // Attachments uploaded for a temporary turn are not kept either. const attIds = turn.userMessage ? (turn.userMessage.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId) : []; if (attIds.length) await db.delete(messageAttachments).where(and(eq(messageAttachments.userId, ctx.userId), inArray(messageAttachments.id, attIds))).catch(() => {}); await touchRecent(ctx.userId, turn.model.key).catch(() => {}); await recordProviderOutcome(ctx.userId, turn.model.provider, error ? { ok: false, code: error.code } : { ok: true }); log.info("chat turn (ephemeral)", { requestId: ctx.requestId, provider: turn.model.provider, model: turn.model.id, status, latencyMs, ttftMs: ttft, inputTokens: usageTotal?.inputTokens, outputTokens: usageTotal?.outputTokens, errorCode: error?.code }); return { message: saved, usage: usageTotal, costUsd, latencyMs, ttftMs: ttft, status, error }; } const [saved] = await db .update(messages) .set({ content: fullText, parts: storedParts, status, finishReason, error: storedError, usage: usageJson, latencyMs, ttftMs: ttft ?? null, costUsd, updatedAt: now, }) .where(eq(messages.id, turn.assistantMessage.id)) .returning(); // conversation aggregates + title let title: string | undefined; if (turn.isNewConversation && turn.userMessage) { title = deriveTitle(turn.userMessage.content) ?? "New chat"; } await db .update(conversations) .set({ ...(title ? { title } : {}), messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${turn.conversation.id} and ${messages.active} = true)`, totalCostUsd: sql`${conversations.totalCostUsd} + ${costUsd ?? 0}`, totalInputTokens: sql`${conversations.totalInputTokens} + ${usageTotal?.inputTokens ?? 0}`, totalOutputTokens: sql`${conversations.totalOutputTokens} + ${usageTotal?.outputTokens ?? 0}`, lastMessageAt: now, updatedAt: now, }) .where(eq(conversations.id, turn.conversation.id)); // usage record (also for errors, so failure rate is visible) await db.insert(usageRecords).values({ id: ids.usage(), userId: ctx.userId, conversationId: turn.conversation.id, messageId: turn.assistantMessage.id, provider: turn.model.provider, modelKey: turn.model.key, kind: "chat", status: error ? "error" : stopped ? "stopped" : "ok", errorCode: error?.code ?? null, inputTokens: usageTotal?.inputTokens ?? 0, outputTokens: usageTotal?.outputTokens ?? 0, cachedTokens: usageTotal?.cachedInputTokens ?? 0, reasoningTokens: usageTotal?.reasoningTokens ?? 0, costUsd, latencyMs, ttftMs: ttft ?? null, }); await touchRecent(ctx.userId, turn.model.key).catch(() => {}); await recordProviderOutcome(ctx.userId, turn.model.provider, error ? { ok: false, code: error.code } : { ok: true }); log.info("chat turn", { requestId: ctx.requestId, provider: turn.model.provider, model: turn.model.id, status, latencyMs, ttftMs: ttft, inputTokens: usageTotal?.inputTokens, outputTokens: usageTotal?.outputTokens, errorCode: error?.code }); return { message: saved, usage: usageTotal, costUsd, latencyMs, ttftMs: ttft, status, error, title }; } /** Error persisted on a failed assistant message: human message + safe diagnostics (no secrets, no raw payloads). */ export interface StoredMessageError { code: string; message: string; provider?: string; status?: number; retryable?: boolean; retryAfterMs?: number; providerCode?: string; /** Provider's own (redacted) message, for the details sheet. */ detail?: string; } export function toStoredError(error: PolyProviderErrorShape): { code: string; message: string } { const out: StoredMessageError = { code: error.code, message: ERROR_MESSAGES[error.code] ?? error.message, provider: error.provider, status: error.status, retryable: error.retryable, retryAfterMs: error.retryAfterMs, providerCode: error.providerCode, detail: error.message && error.message !== (ERROR_MESSAGES[error.code] ?? "") ? error.message.slice(0, 400) : undefined, }; return out; } // --------------------------------------------------------------------------- // Adopt (inline Compare → "Continue with this model") // --------------------------------------------------------------------------- /** * Inserts an assistant message that was generated outside the conversation (Arena / inline Compare) * and switches the conversation to that model. Creates the conversation (with the user turn) when * `conversationId` is missing. No provider call happens here — usage was recorded by the Arena. */ export async function adoptMessage(ctx: ChatContext, input: ChatAdoptInput): Promise<{ conversation: Conversation; userMessage: Message | null; message: Message; isNewConversation: boolean }> { const db = getDb(); const model = await getModel(input.modelKey); if (!model) throw new ApiError(404, "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND"); const now = new Date(); // Content + metrics: prefer the stored Arena response (ownership checked through the session). let content = input.content?.trim() ?? ""; let usage: Record | null = null; let costUsd: number | null = null; let latencyMs: number | null = null; let ttftMs: number | null = null; let reasoning: string | null = null; if (input.arenaResponseId) { const [row] = await db .select({ r: arenaResponses, ownerId: arenaSessions.userId }) .from(arenaResponses) .innerJoin(arenaSessions, eq(arenaResponses.sessionId, arenaSessions.id)) .where(eq(arenaResponses.id, input.arenaResponseId)) .limit(1); if (!row || row.ownerId !== ctx.userId) throw new ApiError(404, "Arena response not found", "NOT_FOUND"); if (row.r.modelKey !== model.key) throw new ApiError(400, "Arena response belongs to another model", "BAD_REQUEST"); content = row.r.content || content; usage = row.r.usage ?? null; costUsd = row.r.costUsd ?? null; latencyMs = row.r.latencyMs ?? null; ttftMs = row.r.ttftMs ?? null; reasoning = row.r.reasoning ?? null; } if (!content) throw new ApiError(400, "Nothing to adopt: the response is empty", "EMPTY_MESSAGE"); let conversation: Conversation; let isNew = false; let userMessage: Message | null = null; if (input.conversationId) { conversation = await loadConversation(ctx.userId, input.conversationId); await db.update(conversations).set({ modelKey: model.key, provider: model.provider, updatedAt: now }).where(eq(conversations.id, conversation.id)); } else { isNew = true; const userText = input.userText!.trim(); const [created] = await db .insert(conversations) .values({ id: ids.conversation(), userId: ctx.userId, title: deriveTitle(userText) ?? "New chat", modelKey: model.key, provider: model.provider, systemPrompt: input.systemPrompt ?? null, settings: { ...(input.settings ?? {}) }, projectId: input.projectId ?? null }) .returning(); conversation = created; const [um] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "user", content: userText, parts: [{ type: "text", text: userText }] satisfies StoredPart[], status: "complete" }) .returning(); userMessage = um; } const parts: StoredPart[] = []; if (reasoning) parts.push({ type: "reasoning", text: reasoning }); parts.push({ type: "text", text: content }); const [am] = await db .insert(messages) .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content, parts, status: "complete", finishReason: "stop", modelKey: model.key, provider: model.provider, settings: input.settings ?? null, usage, costUsd, latencyMs, ttftMs }) .returning(); await db .update(conversations) .set({ messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${conversation.id} and ${messages.active} = true)`, totalCostUsd: sql`${conversations.totalCostUsd} + ${costUsd ?? 0}`, totalInputTokens: sql`${conversations.totalInputTokens} + ${usage?.inputTokens ?? 0}`, totalOutputTokens: sql`${conversations.totalOutputTokens} + ${usage?.outputTokens ?? 0}`, lastMessageAt: now, updatedAt: now, }) .where(eq(conversations.id, conversation.id)); const [fresh] = await db.select().from(conversations).where(eq(conversations.id, conversation.id)).limit(1); await touchRecent(ctx.userId, model.key).catch(() => {}); log.info("chat adopt", { requestId: ctx.requestId, model: model.key, conversationId: conversation.id, fromArena: Boolean(input.arenaResponseId), isNew }); return { conversation: fresh ?? conversation, userMessage, message: am, isNewConversation: isNew }; } /** Some providers wrap JSON-mode answers in ```json fences; unwrap when the whole answer is one fenced block. */ export function stripJsonFence(text: string): string { const m = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); return m ? m[1] : text; } export function deriveTitle(text: string): string | undefined { const clean = textOf([{ type: "text", text }]) .replace(/[#*`>_~\[\]()]/g, "") .replace(/\s+/g, " ") .trim(); if (!clean) return undefined; const firstSentence = clean.split(/(?<=[.!?])\s/)[0] ?? clean; const t = firstSentence.length > 64 ? `${firstSentence.slice(0, 63).trimEnd()}…` : firstSentence; return t.charAt(0).toUpperCase() + t.slice(1); } /** Public message shape sent to the browser (attachments without payload). */ export function toPublicMessage(m: Message) { return { id: m.id, conversationId: m.conversationId, role: m.role, content: m.content, parts: m.parts as StoredPart[], modelKey: m.modelKey, provider: m.provider, status: m.status, finishReason: m.finishReason, error: m.error as StoredMessageError | null, usage: m.usage, /** Generation settings used for this assistant turn (reasoning effort, temperature…). */ settings: (m.settings ?? null) as Record | null, latencyMs: m.latencyMs, ttftMs: m.ttftMs, costUsd: m.costUsd, parentMessageId: m.parentMessageId, version: m.version, active: m.active, createdAt: m.createdAt.toISOString(), }; } export type PublicMessage = ReturnType;