TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import "server-only";2import { and, asc, eq, inArray, sql } from "drizzle-orm";3import { getDb, conversations, messages, messageAttachments, usageRecords, arenaResponses, arenaSessions, type Conversation, type Message } from "@/db";4import { ids } from "@/lib/ids";5import { log } from "@/lib/log";6import { ApiError } from "@/lib/api";7import { getModel, touchRecent } from "@/lib/ai/registry";8import { getAdapter } from "@/lib/ai/providers";9import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys";10import { isCustomModelKey, resolveCustomEndpoint } from "@/lib/endpoints/service";11import type { AIProviderAdapter } from "@/lib/ai/core/types";12import { estimateCost } from "@/lib/ai/core/pricing";13import { StreamAccumulator } from "@/lib/ai/core/stream-utils";14import { filterSettings } from "@/lib/ai/core/normalize";15import { ERROR_MESSAGES } from "@/lib/ai/core/errors";16import { textOf } from "@/lib/ai/core/content";17import type { ContentPart, PolyModel, UnifiedGenerationSettings, UnifiedMessage, UnifiedStreamEvent, Usage, PolyProviderErrorShape } from "@/lib/ai/core/types";18import { resolveTools, runBuiltinTool } from "./tools";19import type { ChatAdoptInput, ChatRequestInput, GenerationSettingsInput } from "./schemas";2021export type StoredPart =22 | { type: "text"; text: string }23 | { type: "reasoning"; text: string; signature?: string; providerData?: unknown; durationMs?: number }24 | { type: "attachment"; attachmentId: string; kind: string; name: string; mimeType: string; sizeBytes: number; width?: number | null; height?: number | null }25 | { type: "tool-call"; id: string; name: string; arguments: Record<string, unknown>; argumentsText?: string; providerData?: unknown; result?: unknown; isError?: boolean; durationMs?: number }26 | { type: "server-tool"; name: string; status: string; data?: unknown }27 | { type: "citation"; url?: string; title?: string; snippet?: string }28 | { type: "refusal"; category?: string | null; explanation?: string | null };2930const MAX_TOOL_ROUNDS = 5;3132// ---------------------------------------------------------------------------33// Loading34// ---------------------------------------------------------------------------35export async function loadConversation(userId: string, id: string): Promise<Conversation> {36 const [c] = await getDb()37 .select()38 .from(conversations)39 .where(and(eq(conversations.id, id), eq(conversations.userId, userId)))40 .limit(1);41 if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND");42 return c;43}4445export async function loadActiveMessages(conversationId: string): Promise<Message[]> {46 return getDb()47 .select()48 .from(messages)49 .where(and(eq(messages.conversationId, conversationId), eq(messages.active, true)))50 .orderBy(asc(messages.createdAt));51}5253async function loadAttachments(userId: string, attachmentIds: string[]) {54 if (!attachmentIds.length) return [];55 return getDb()56 .select()57 .from(messageAttachments)58 .where(and(eq(messageAttachments.userId, userId), inArray(messageAttachments.id, attachmentIds)));59}6061/** Convert stored messages (with attachment references) into provider-neutral messages. */62export async function toUnifiedMessages(userId: string, rows: Message[], model: PolyModel): Promise<UnifiedMessage[]> {63 const attIds = rows.flatMap((r) => (r.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId));64 const atts = await loadAttachments(userId, attIds);65 const byId = new Map(atts.map((a) => [a.id, a]));66 const out: UnifiedMessage[] = [];67 for (const r of rows) {68 if (r.status === "error" && r.role === "assistant" && !r.content) continue;69 const parts: ContentPart[] = [];70 const toolResults: ContentPart[] = [];71 for (const p of r.parts as StoredPart[]) {72 switch (p.type) {73 case "text":74 if (p.text) parts.push({ type: "text", text: p.text });75 break;76 case "reasoning":77 if (p.text) parts.push({ type: "reasoning", text: p.text, signature: p.signature, providerData: p.providerData });78 break;79 case "attachment": {80 const a = byId.get(p.attachmentId);81 if (!a) break;82 if (a.kind === "image" && model.capabilities.vision) parts.push({ type: "image", mimeType: a.mimeType, data: a.dataBase64, name: a.name });83 else if (a.kind !== "image") parts.push({ type: "file", mimeType: a.mimeType, data: a.dataBase64, name: a.name });84 else parts.push({ type: "text", text: `[Image "${a.name}" omitted — this model has no vision]` });85 break;86 }87 case "tool-call":88 parts.push({ type: "tool-call", id: p.id, name: p.name, arguments: p.arguments, argumentsText: p.argumentsText, providerData: p.providerData });89 if (p.result !== undefined) toolResults.push({ type: "tool-result", toolCallId: p.id, name: p.name, result: p.result, isError: p.isError });90 break;91 default:92 break;93 }94 }95 if (!parts.length) continue;96 out.push({ role: r.role as UnifiedMessage["role"], content: parts, providerData: r.modelKey ? { model: r.modelKey.split("/").slice(1).join("/") } : undefined });97 if (toolResults.length) out.push({ role: "tool", content: toolResults });98 }99 return out;100}101102// ---------------------------------------------------------------------------103// Orchestration104// ---------------------------------------------------------------------------105export interface ChatContext {106 userId: string;107 requestId: string;108 ip: string;109}110111export interface PreparedTurn {112 conversation: Conversation;113 model: PolyModel;114 apiKey: string;115 /** Custom endpoint adapter; undefined = registry provider adapter. */116 adapter?: AIProviderAdapter;117 history: UnifiedMessage[];118 userMessage: Message | null;119 assistantMessage: Message;120 settings: UnifiedGenerationSettings;121 toolIds: string[];122 systemPrompt: string | undefined;123 isNewConversation: boolean;124 continuation: boolean;125 /** Temporary chat: nothing but the usage record is persisted. */126 ephemeral: boolean;127}128129/** Sentinel conversation id used for temporary chats (never written to the database). */130export const EPHEMERAL_CONVERSATION_ID = "ephemeral";131132function syntheticMessage(userId: string, conversationId: string, role: "user" | "assistant", content: string, parts: StoredPart[], extra: Partial<Message> = {}): Message {133 const now = new Date();134 return {135 id: ids.message(),136 conversationId,137 userId,138 role,139 content,140 parts,141 modelKey: null,142 provider: null,143 status: "complete",144 finishReason: null,145 error: null,146 usage: null,147 settings: null,148 latencyMs: null,149 ttftMs: null,150 costUsd: null,151 parentMessageId: null,152 version: 1,153 active: true,154 createdAt: now,155 updatedAt: now,156 ...extra,157 } as Message;158}159160function toUnifiedSettings(s: GenerationSettingsInput | undefined): { settings: UnifiedGenerationSettings; toolIds: string[] } {161 if (!s) return { settings: {}, toolIds: [] };162 const { tools, ...rest } = s;163 return { settings: rest as UnifiedGenerationSettings, toolIds: tools ?? [] };164}165166export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Promise<PreparedTurn> {167 const db = getDb();168 // Custom OpenAI-compatible endpoints (Settings → Endpoints) resolve to their own adapter + key.169 const custom = isCustomModelKey(input.modelKey) ? await resolveCustomEndpoint(ctx.userId, input.modelKey) : null;170 const model = custom?.model ?? (await getModel(input.modelKey));171 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");172 const apiKey = custom?.apiKey ?? (await getDecryptedKey(ctx.userId, model.provider));173 if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. Add one in Settings → Providers.`, "NO_PROVIDER_KEY", { provider: model.provider });174 const adapter = custom?.adapter;175176 const { settings: rawSettings, toolIds } = toUnifiedSettings(input.settings);177 const { settings } = filterSettings(rawSettings, model);178 const now = new Date();179180 // --- temporary chat: nothing touches conversations/messages ---------------181 if (input.ephemeral) {182 if (input.conversationId || input.action !== "send") throw new ApiError(400, "Temporary chats only accept new messages", "BAD_REQUEST");183 if (!input.message || (!input.message.text.trim() && !input.message.attachmentIds?.length)) throw new ApiError(400, "Message is empty", "EMPTY_MESSAGE");184 const conversation = {185 id: EPHEMERAL_CONVERSATION_ID,186 userId: ctx.userId,187 title: "Temporary chat",188 titleSource: "auto",189 folderId: null,190 projectId: null,191 pinned: false,192 archived: false,193 modelKey: model.key,194 provider: model.provider,195 systemPrompt: input.systemPrompt ?? null,196 settings: { ...(input.settings ?? {}) },197 parentConversationId: null,198 branchedFromMessageId: null,199 messageCount: 0,200 totalCostUsd: 0,201 totalInputTokens: 0,202 totalOutputTokens: 0,203 lastMessageAt: null,204 createdAt: now,205 updatedAt: now,206 } as Conversation;207 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 } : {}));208 const atts = await loadAttachments(ctx.userId, input.message.attachmentIds ?? []);209 const parts: StoredPart[] = [];210 if (input.message.text.trim()) parts.push({ type: "text", text: input.message.text });211 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 });212 const userMessage = syntheticMessage(ctx.userId, conversation.id, "user", input.message.text, parts);213 const assistantMessage = syntheticMessage(ctx.userId, conversation.id, "assistant", "", [], { status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null });214 const history = await toUnifiedMessages(ctx.userId, [...rows, userMessage], model);215 return {216 adapter,217 conversation, model, apiKey, history, userMessage, assistantMessage, settings, toolIds, systemPrompt: input.systemPrompt ?? undefined, isNewConversation: false, continuation: false, ephemeral: true };218 }219220 // --- conversation --------------------------------------------------------221 let conversation: Conversation;222 let isNew = false;223 if (input.conversationId) {224 conversation = await loadConversation(ctx.userId, input.conversationId);225 await db226 .update(conversations)227 .set({ modelKey: model.key, provider: model.provider, settings: { ...(input.settings ?? {}) }, systemPrompt: input.systemPrompt === undefined ? conversation.systemPrompt : input.systemPrompt, updatedAt: now })228 .where(eq(conversations.id, conversation.id));229 } else {230 if (input.action !== "send") throw new ApiError(400, "A conversation is required for this action", "BAD_REQUEST");231 isNew = true;232 const [created] = await db233 .insert(conversations)234 .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 })235 .returning();236 conversation = created;237 }238 const systemPrompt = (input.systemPrompt === undefined ? conversation.systemPrompt : input.systemPrompt) ?? undefined;239240 // --- history + new message ------------------------------------------------241 let rows = await loadActiveMessages(conversation.id);242 let userMessage: Message | null = null;243 let continuation = false;244245 if (input.action === "send") {246 if (!input.message || (!input.message.text.trim() && !input.message.attachmentIds?.length)) throw new ApiError(400, "Message is empty", "EMPTY_MESSAGE");247 const atts = await loadAttachments(ctx.userId, input.message.attachmentIds ?? []);248 const parts: StoredPart[] = [];249 if (input.message.text.trim()) parts.push({ type: "text", text: input.message.text });250 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 });251 const [um] = await db252 .insert(messages)253 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "user", content: input.message.text, parts, status: "complete" })254 .returning();255 userMessage = um;256 if (atts.length) await db.update(messageAttachments).set({ messageId: um.id, conversationId: conversation.id }).where(inArray(messageAttachments.id, atts.map((a) => a.id)));257 rows = [...rows, um];258 } else if (input.action === "edit") {259 if (!input.targetMessageId || !input.message) throw new ApiError(400, "targetMessageId and message are required", "BAD_REQUEST");260 const target = rows.find((r) => r.id === input.targetMessageId && r.role === "user");261 if (!target) throw new ApiError(404, "Message not found", "NOT_FOUND");262 const idx = rows.indexOf(target);263 // deactivate the edited message and everything after it (they belong to the old branch)264 const toDeactivate = rows.slice(idx).map((r) => r.id);265 await db.update(messages).set({ active: false, updatedAt: now }).where(inArray(messages.id, toDeactivate));266 const keptParts = (target.parts as StoredPart[]).filter((p) => p.type === "attachment");267 const parts: StoredPart[] = [{ type: "text", text: input.message.text }, ...keptParts];268 const [um] = await db269 .insert(messages)270 .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 })271 .returning();272 userMessage = um;273 rows = [...rows.slice(0, idx), um];274 } else if (input.action === "regenerate" || input.action === "retry") {275 if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST");276 const target = rows.find((r) => r.id === input.targetMessageId && r.role === "assistant");277 if (!target) throw new ApiError(404, "Message not found", "NOT_FOUND");278 const idx = rows.indexOf(target);279 const toDeactivate = rows.slice(idx).map((r) => r.id);280 await db.update(messages).set({ active: false, updatedAt: now }).where(inArray(messages.id, toDeactivate));281 rows = rows.slice(0, idx);282 // create the new assistant version below283 const [am] = await db284 .insert(messages)285 .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 })286 .returning();287 const history = await toUnifiedMessages(ctx.userId, rows, model);288 return {289 adapter,290 conversation, model, apiKey, history, userMessage: null, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: false, continuation: false, ephemeral: false };291 } else if (input.action === "continue") {292 if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST");293 const target = rows[rows.length - 1];294 if (!target || target.id !== input.targetMessageId || target.role !== "assistant") throw new ApiError(400, "Only the last assistant message can be continued", "BAD_REQUEST");295 continuation = true;296 await db.update(messages).set({ status: "streaming", updatedAt: now }).where(eq(messages.id, target.id));297 const history = await toUnifiedMessages(ctx.userId, rows, model);298 history.push({ role: "user", content: [{ type: "text", text: "Continue exactly where you left off, without repeating anything you already wrote." }] });299 return {300 adapter,301 conversation, model, apiKey, history, userMessage: null, assistantMessage: target, settings, toolIds, systemPrompt, isNewConversation: false, continuation, ephemeral: false };302 }303304 const [am] = await db305 .insert(messages)306 .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 })307 .returning();308 const history = await toUnifiedMessages(ctx.userId, rows, model);309 return {310 adapter,311 conversation, model, apiKey, history, userMessage, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: isNew, continuation, ephemeral: false };312}313314export interface TurnOutcome {315 message: Message;316 usage?: Usage;317 costUsd: number | null;318 latencyMs: number;319 ttftMs?: number;320 status: "complete" | "stopped" | "error";321 error?: PolyProviderErrorShape;322 title?: string;323}324325/**326 * Runs the provider stream (with built-in tool loop), emitting unified events to `emit`327 * and persisting the assistant message at the end. Never throws for provider errors —328 * they are emitted and stored on the message.329 */330export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: unknown) => void, signal: AbortSignal): Promise<TurnOutcome> {331 const db = getDb();332 const adapter = turn.adapter ?? getAdapter(turn.model.provider);333 const tools = resolveTools(turn.toolIds).filter(() => turn.model.capabilities.tools);334 const t0 = Date.now();335 const previousParts = turn.continuation ? (turn.assistantMessage.parts as StoredPart[]) : [];336 const previousText = turn.continuation ? turn.assistantMessage.content : "";337338 let history = [...turn.history];339 const storedParts: StoredPart[] = [...previousParts];340 let fullText = previousText;341 let reasoningStart: number | undefined;342 let usageTotal: Usage | undefined;343 let ttft: number | undefined;344 let finish: string | undefined;345 let error: PolyProviderErrorShape | undefined;346 let stopped = false;347 const providerData: Record<string, unknown> = {};348349 const addUsage = (u: Usage) => {350 usageTotal = usageTotal351 ? {352 inputTokens: usageTotal.inputTokens + u.inputTokens,353 outputTokens: usageTotal.outputTokens + u.outputTokens,354 cachedInputTokens: (usageTotal.cachedInputTokens ?? 0) + (u.cachedInputTokens ?? 0),355 reasoningTokens: (usageTotal.reasoningTokens ?? 0) + (u.reasoningTokens ?? 0),356 totalTokens: (usageTotal.totalTokens ?? 0) + (u.totalTokens ?? 0),357 }358 : { ...u };359 };360361 for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {362 const acc = new StreamAccumulator();363 let roundText = "";364 let roundReasoning = "";365 let roundSignature: string | undefined;366 let roundReasoningData: unknown;367 const stream = adapter.streamChat({368 provider: turn.model.provider,369 model: turn.model.id,370 apiKey: turn.apiKey,371 system: turn.systemPrompt,372 messages: history,373 settings: turn.settings,374 tools: tools.length ? tools.map((t) => t.definition) : undefined,375 modelInfo: turn.model,376 signal,377 requestId: ctx.requestId,378 });379380 try {381 for await (const ev of stream as AsyncIterable<UnifiedStreamEvent>) {382 if (signal.aborted) {383 stopped = true;384 break;385 }386 acc.push(ev);387 switch (ev.type) {388 case "text-delta":389 if (ttft === undefined) ttft = Date.now() - t0;390 roundText += ev.text;391 fullText += ev.text;392 emit(ev);393 break;394 case "reasoning-delta":395 if (ttft === undefined) ttft = Date.now() - t0;396 if (reasoningStart === undefined) reasoningStart = Date.now();397 roundReasoning += ev.text;398 emit(ev);399 break;400 case "reasoning-signature":401 roundSignature = ev.signature;402 roundReasoningData = ev.providerData;403 break;404 case "usage":405 addUsage(ev.usage);406 break;407 case "provider-data":408 Object.assign(providerData, ev.data);409 if (ev.data.refusal) {410 storedParts.push({ type: "refusal", ...(ev.data.refusal as { category?: string | null; explanation?: string | null }) });411 emit({ type: "refusal", ...(ev.data.refusal as object) });412 }413 break;414 case "citation":415 storedParts.push({ type: "citation", url: ev.citation.url, title: ev.citation.title, snippet: ev.citation.snippet });416 emit(ev);417 break;418 case "server-tool":419 storedParts.push({ type: "server-tool", name: ev.name, status: ev.status, data: ev.data });420 emit(ev);421 break;422 case "tool-start":423 case "tool-delta":424 emit(ev);425 break;426 case "tool-end":427 // handled after the stream via acc.toolCallParts()428 break;429 case "finish":430 finish = ev.reason;431 break;432 case "error":433 error = ev.error;434 break;435 default:436 break;437 }438 }439 } catch (e) {440 error = adapter.normalizeError(e).toJSON();441 }442443 // persist this round's reasoning + text444 if (roundReasoning) storedParts.push({ type: "reasoning", text: roundReasoning, signature: roundSignature, providerData: roundReasoningData, durationMs: reasoningStart ? Date.now() - reasoningStart : undefined });445 if (roundText) storedParts.push({ type: "text", text: roundText });446447 if (error || stopped) break;448449 const calls = acc.toolCallParts().filter((c) => c.name);450 if (!calls.length || acc.finishReason !== "tool-calls" || round === MAX_TOOL_ROUNDS) break;451452 // --- execute built-in tools and loop -------------------------------------453 const assistantParts: ContentPart[] = [];454 if (roundReasoning && roundSignature) assistantParts.push({ type: "reasoning", text: roundReasoning, signature: roundSignature, providerData: roundReasoningData });455 if (roundText) assistantParts.push({ type: "text", text: roundText });456 assistantParts.push(...calls);457 const results: ContentPart[] = [];458 for (const call of calls) {459 emit({ type: "tool-end", id: call.id, name: call.name, arguments: call.arguments, argumentsText: call.argumentsText });460 const r = await runBuiltinTool(call.name, call.arguments);461 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 });462 emit({ type: "tool-result", id: call.id, name: call.name, result: r.result, isError: r.isError, durationMs: r.durationMs });463 results.push({ type: "tool-result", toolCallId: call.id, name: call.name, result: r.result, isError: r.isError });464 }465 history = [...history, { role: "assistant", content: assistantParts, providerData: { model: turn.model.id } }, { role: "tool", content: results }];466 }467468 // --- finalize ---------------------------------------------------------------469 if (turn.settings.responseFormat && turn.settings.responseFormat.type !== "text") {470 const unfenced = stripJsonFence(fullText);471 if (unfenced !== fullText) {472 fullText = unfenced;473 for (let i = storedParts.length - 1; i >= 0; i--) {474 const part = storedParts[i];475 if (part.type === "text") {476 part.text = stripJsonFence(part.text);477 break;478 }479 }480 }481 }482 const latencyMs = Date.now() - t0;483 const cost = estimateCost(usageTotal, turn.model.pricing);484 const exactCost = typeof providerData.exactCostUsd === "number" ? (providerData.exactCostUsd as number) : null;485 const costUsd = exactCost ?? (cost.known ? cost.totalUsd : null);486 const status: TurnOutcome["status"] = error ? (fullText ? "stopped" : "error") : stopped ? "stopped" : "complete";487 const now = new Date();488 // Stored error keeps the safe diagnostic fields (provider code, HTTP status, retry hint) so the UI can explain it.489 const storedError = error ? toStoredError(error) : null;490 const finishReason = finish ?? (stopped ? "cancelled" : error ? "error" : null);491 const usageJson = usageTotal ? (usageTotal as unknown as Record<string, number>) : null;492493 if (turn.ephemeral) {494 // Temporary chat: no conversation/message rows. Only the usage record is written (conversation null).495 const saved: Message = { ...turn.assistantMessage, content: fullText, parts: storedParts, status, finishReason, error: storedError, usage: usageJson, latencyMs, ttftMs: ttft ?? null, costUsd, updatedAt: now };496 await db.insert(usageRecords).values({497 id: ids.usage(),498 userId: ctx.userId,499 conversationId: null,500 messageId: null,501 provider: turn.model.provider,502 modelKey: turn.model.key,503 kind: "chat",504 status: error ? "error" : stopped ? "stopped" : "ok",505 errorCode: error?.code ?? null,506 inputTokens: usageTotal?.inputTokens ?? 0,507 outputTokens: usageTotal?.outputTokens ?? 0,508 cachedTokens: usageTotal?.cachedInputTokens ?? 0,509 reasoningTokens: usageTotal?.reasoningTokens ?? 0,510 costUsd,511 latencyMs,512 ttftMs: ttft ?? null,513 });514 // Attachments uploaded for a temporary turn are not kept either.515 const attIds = turn.userMessage ? (turn.userMessage.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId) : [];516 if (attIds.length) await db.delete(messageAttachments).where(and(eq(messageAttachments.userId, ctx.userId), inArray(messageAttachments.id, attIds))).catch(() => {});517 await touchRecent(ctx.userId, turn.model.key).catch(() => {});518 await recordProviderOutcome(ctx.userId, turn.model.provider, error ? { ok: false, code: error.code } : { ok: true });519 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 });520 return { message: saved, usage: usageTotal, costUsd, latencyMs, ttftMs: ttft, status, error };521 }522523 const [saved] = await db524 .update(messages)525 .set({526 content: fullText,527 parts: storedParts,528 status,529 finishReason,530 error: storedError,531 usage: usageJson,532 latencyMs,533 ttftMs: ttft ?? null,534 costUsd,535 updatedAt: now,536 })537 .where(eq(messages.id, turn.assistantMessage.id))538 .returning();539540 // conversation aggregates + title541 let title: string | undefined;542 if (turn.isNewConversation && turn.userMessage) {543 title = deriveTitle(turn.userMessage.content) ?? "New chat";544 }545 await db546 .update(conversations)547 .set({548 ...(title ? { title } : {}),549 messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${turn.conversation.id} and ${messages.active} = true)`,550 totalCostUsd: sql`${conversations.totalCostUsd} + ${costUsd ?? 0}`,551 totalInputTokens: sql`${conversations.totalInputTokens} + ${usageTotal?.inputTokens ?? 0}`,552 totalOutputTokens: sql`${conversations.totalOutputTokens} + ${usageTotal?.outputTokens ?? 0}`,553 lastMessageAt: now,554 updatedAt: now,555 })556 .where(eq(conversations.id, turn.conversation.id));557558 // usage record (also for errors, so failure rate is visible)559 await db.insert(usageRecords).values({560 id: ids.usage(),561 userId: ctx.userId,562 conversationId: turn.conversation.id,563 messageId: turn.assistantMessage.id,564 provider: turn.model.provider,565 modelKey: turn.model.key,566 kind: "chat",567 status: error ? "error" : stopped ? "stopped" : "ok",568 errorCode: error?.code ?? null,569 inputTokens: usageTotal?.inputTokens ?? 0,570 outputTokens: usageTotal?.outputTokens ?? 0,571 cachedTokens: usageTotal?.cachedInputTokens ?? 0,572 reasoningTokens: usageTotal?.reasoningTokens ?? 0,573 costUsd,574 latencyMs,575 ttftMs: ttft ?? null,576 });577 await touchRecent(ctx.userId, turn.model.key).catch(() => {});578 await recordProviderOutcome(ctx.userId, turn.model.provider, error ? { ok: false, code: error.code } : { ok: true });579580 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 });581582 return { message: saved, usage: usageTotal, costUsd, latencyMs, ttftMs: ttft, status, error, title };583}584585/** Error persisted on a failed assistant message: human message + safe diagnostics (no secrets, no raw payloads). */586export interface StoredMessageError {587 code: string;588 message: string;589 provider?: string;590 status?: number;591 retryable?: boolean;592 retryAfterMs?: number;593 providerCode?: string;594 /** Provider's own (redacted) message, for the details sheet. */595 detail?: string;596}597598export function toStoredError(error: PolyProviderErrorShape): { code: string; message: string } {599 const out: StoredMessageError = {600 code: error.code,601 message: ERROR_MESSAGES[error.code] ?? error.message,602 provider: error.provider,603 status: error.status,604 retryable: error.retryable,605 retryAfterMs: error.retryAfterMs,606 providerCode: error.providerCode,607 detail: error.message && error.message !== (ERROR_MESSAGES[error.code] ?? "") ? error.message.slice(0, 400) : undefined,608 };609 return out;610}611612// ---------------------------------------------------------------------------613// Adopt (inline Compare → "Continue with this model")614// ---------------------------------------------------------------------------615/**616 * Inserts an assistant message that was generated outside the conversation (Arena / inline Compare)617 * and switches the conversation to that model. Creates the conversation (with the user turn) when618 * `conversationId` is missing. No provider call happens here — usage was recorded by the Arena.619 */620export async function adoptMessage(ctx: ChatContext, input: ChatAdoptInput): Promise<{ conversation: Conversation; userMessage: Message | null; message: Message; isNewConversation: boolean }> {621 const db = getDb();622 const model = await getModel(input.modelKey);623 if (!model) throw new ApiError(404, "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND");624 const now = new Date();625626 // Content + metrics: prefer the stored Arena response (ownership checked through the session).627 let content = input.content?.trim() ?? "";628 let usage: Record<string, number> | null = null;629 let costUsd: number | null = null;630 let latencyMs: number | null = null;631 let ttftMs: number | null = null;632 let reasoning: string | null = null;633 if (input.arenaResponseId) {634 const [row] = await db635 .select({ r: arenaResponses, ownerId: arenaSessions.userId })636 .from(arenaResponses)637 .innerJoin(arenaSessions, eq(arenaResponses.sessionId, arenaSessions.id))638 .where(eq(arenaResponses.id, input.arenaResponseId))639 .limit(1);640 if (!row || row.ownerId !== ctx.userId) throw new ApiError(404, "Arena response not found", "NOT_FOUND");641 if (row.r.modelKey !== model.key) throw new ApiError(400, "Arena response belongs to another model", "BAD_REQUEST");642 content = row.r.content || content;643 usage = row.r.usage ?? null;644 costUsd = row.r.costUsd ?? null;645 latencyMs = row.r.latencyMs ?? null;646 ttftMs = row.r.ttftMs ?? null;647 reasoning = row.r.reasoning ?? null;648 }649 if (!content) throw new ApiError(400, "Nothing to adopt: the response is empty", "EMPTY_MESSAGE");650651 let conversation: Conversation;652 let isNew = false;653 let userMessage: Message | null = null;654 if (input.conversationId) {655 conversation = await loadConversation(ctx.userId, input.conversationId);656 await db.update(conversations).set({ modelKey: model.key, provider: model.provider, updatedAt: now }).where(eq(conversations.id, conversation.id));657 } else {658 isNew = true;659 const userText = input.userText!.trim();660 const [created] = await db661 .insert(conversations)662 .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 })663 .returning();664 conversation = created;665 const [um] = await db666 .insert(messages)667 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "user", content: userText, parts: [{ type: "text", text: userText }] satisfies StoredPart[], status: "complete" })668 .returning();669 userMessage = um;670 }671672 const parts: StoredPart[] = [];673 if (reasoning) parts.push({ type: "reasoning", text: reasoning });674 parts.push({ type: "text", text: content });675 const [am] = await db676 .insert(messages)677 .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 })678 .returning();679680 await db681 .update(conversations)682 .set({683 messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${conversation.id} and ${messages.active} = true)`,684 totalCostUsd: sql`${conversations.totalCostUsd} + ${costUsd ?? 0}`,685 totalInputTokens: sql`${conversations.totalInputTokens} + ${usage?.inputTokens ?? 0}`,686 totalOutputTokens: sql`${conversations.totalOutputTokens} + ${usage?.outputTokens ?? 0}`,687 lastMessageAt: now,688 updatedAt: now,689 })690 .where(eq(conversations.id, conversation.id));691 const [fresh] = await db.select().from(conversations).where(eq(conversations.id, conversation.id)).limit(1);692 await touchRecent(ctx.userId, model.key).catch(() => {});693 log.info("chat adopt", { requestId: ctx.requestId, model: model.key, conversationId: conversation.id, fromArena: Boolean(input.arenaResponseId), isNew });694 return { conversation: fresh ?? conversation, userMessage, message: am, isNewConversation: isNew };695}696697/** Some providers wrap JSON-mode answers in ```json fences; unwrap when the whole answer is one fenced block. */698export function stripJsonFence(text: string): string {699 const m = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);700 return m ? m[1] : text;701}702703export function deriveTitle(text: string): string | undefined {704 const clean = textOf([{ type: "text", text }])705 .replace(/[#*`>_~\[\]()]/g, "")706 .replace(/\s+/g, " ")707 .trim();708 if (!clean) return undefined;709 const firstSentence = clean.split(/(?<=[.!?])\s/)[0] ?? clean;710 const t = firstSentence.length > 64 ? `${firstSentence.slice(0, 63).trimEnd()}…` : firstSentence;711 return t.charAt(0).toUpperCase() + t.slice(1);712}713714/** Public message shape sent to the browser (attachments without payload). */715export function toPublicMessage(m: Message) {716 return {717 id: m.id,718 conversationId: m.conversationId,719 role: m.role,720 content: m.content,721 parts: m.parts as StoredPart[],722 modelKey: m.modelKey,723 provider: m.provider,724 status: m.status,725 finishReason: m.finishReason,726 error: m.error as StoredMessageError | null,727 usage: m.usage,728 /** Generation settings used for this assistant turn (reasoning effort, temperature…). */729 settings: (m.settings ?? null) as Record<string, unknown> | null,730 latencyMs: m.latencyMs,731 ttftMs: m.ttftMs,732 costUsd: m.costUsd,733 parentMessageId: m.parentMessageId,734 version: m.version,735 active: m.active,736 createdAt: m.createdAt.toISOString(),737 };738}739export type PublicMessage = ReturnType<typeof toPublicMessage>;740