SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
35.6 KB · 740 lines typescript
Raw Blame History
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