Preserve response model attribution on the deployed backend base
Backend and API documentation changes from b9ff0a7; retains the currently deployed 5d738f8 backend feature set.
This commit is contained in:
@@ -85,6 +85,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
chatId: call.chatId,
|
||||
role: "assistant" as any,
|
||||
content: outText,
|
||||
metadata: { generation: { provider: req.provider, model: req.model } },
|
||||
},
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
|
||||
@@ -123,7 +123,10 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId,
|
||||
role: "assistant" as any,
|
||||
content: text,
|
||||
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||
metadata: {
|
||||
...(req.clientRequestId ? { clientRequestId: req.clientRequestId } : {}),
|
||||
generation: { provider: req.provider, model: req.model },
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.llmCall.update({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fromPrismaProvider } from "./llm/provider-ids.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
type RecordedCall = {
|
||||
provider: unknown;
|
||||
model: string;
|
||||
request: unknown;
|
||||
error: string | null;
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
/** Recover older streaming replies only through an unambiguous, completed request ID. */
|
||||
export function withMessageGenerations<T extends { role: string; metadata: unknown }>(messages: T[], calls: RecordedCall[]): T[] {
|
||||
const byRequest = new Map<string, { provider: string; model: string } | null>();
|
||||
for (const call of calls) {
|
||||
const requestId = record(call.request).clientRequestId;
|
||||
if (typeof requestId !== "string" || !requestId || call.error !== null || call.latencyMs === null) continue;
|
||||
const provider = fromPrismaProvider(call.provider);
|
||||
if (!provider || !call.model.trim()) continue;
|
||||
byRequest.set(requestId, byRequest.has(requestId) ? null : { provider, model: call.model });
|
||||
}
|
||||
return messages.map(message => {
|
||||
const metadata = record(message.metadata);
|
||||
if (message.role !== "assistant" || metadata.agentAside === true || metadata.generation !== undefined) return message;
|
||||
const generation = typeof metadata.clientRequestId === "string" ? byRequest.get(metadata.clientRequestId) : null;
|
||||
return generation ? { ...message, metadata: { ...metadata, generation } } : message;
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
|
||||
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
|
||||
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { withMessageGenerations } from "./message-generation.js";
|
||||
import { requireAdmin } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||
@@ -1173,11 +1174,12 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
|
||||
enabledTools: true,
|
||||
userId: true,
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
calls: { select: { provider: true, model: true, request: true, error: true, latencyMs: true } },
|
||||
},
|
||||
});
|
||||
if (!source) return { status: "chat-not-found" as const };
|
||||
|
||||
let messages = source.messages;
|
||||
let messages = withMessageGenerations(source.messages, source.calls);
|
||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||
if (parsed.data.messageId) {
|
||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||
@@ -1680,7 +1682,7 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
|
||||
},
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat: serializeChatLike(chat) };
|
||||
return { chat: serializeChatLike({ ...chat, messages: withMessageGenerations(chat.messages, chat.calls) }) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { withMessageGenerations } from "../src/message-generation.js";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-message-generation-"));
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.ADMIN_TOKEN = "message-generation-test-token";
|
||||
process.env.OPENAI_API_KEY = "synthetic-openai-key";
|
||||
process.env.XAI_API_KEY = "synthetic-xai-key";
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
||||
cwd: serverRoot, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" }, stdio: "pipe",
|
||||
});
|
||||
const [{ registerRoutes }, { prisma }, { runMultiplex }, { runMultiplexStream }] = await Promise.all([
|
||||
import("../src/routes.js"), import("../src/db.js"), import("../src/llm/multiplexer.js"), import("../src/llm/streaming.js"),
|
||||
]);
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
const auth = { authorization: "Bearer message-generation-test-token" };
|
||||
test.after(async () => { await app.close(); await prisma.$disconnect(); rmSync(databaseDir, { recursive: true, force: true }); });
|
||||
|
||||
test("streaming and non-streaming replies retain their own model across selection changes and forks", async t => {
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.ok(["https://api.openai.com/v1/chat/completions", "https://api.x.ai/v1/chat/completions"].includes(url), url);
|
||||
const body = JSON.parse(input instanceof Request ? await input.text() : String(init?.body));
|
||||
const content = `Reply from ${body.model}`;
|
||||
return body.stream
|
||||
? new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } })
|
||||
: Response.json({ choices: [{ message: { role: "assistant", content } }] });
|
||||
});
|
||||
const chat = await prisma.chat.create({ data: { title: "Mixed models" } });
|
||||
const messages = [{ role: "user" as const, content: "Answer" }];
|
||||
const events = [];
|
||||
for await (const event of runMultiplexStream({ chatId: chat.id, provider: "openai", model: "model-one", clientRequestId: "first-turn", messages, enabledTools: [] })) events.push(event);
|
||||
assert.equal(events.at(-1)?.type, "done");
|
||||
await runMultiplex({ chatId: chat.id, provider: "xai", model: "model-two", messages, enabledTools: [] });
|
||||
await prisma.chat.update({ where: { id: chat.id }, data: { lastUsedProvider: "gemini", lastUsedModel: "model-next" } });
|
||||
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
|
||||
assert.equal(detail.statusCode, 200);
|
||||
const saved = detail.json().chat.messages;
|
||||
assert.deepEqual(saved.map((m: any) => m.metadata.generation), [{ provider: "openai", model: "model-one" }, { provider: "xai", model: "model-two" }]);
|
||||
assert.equal(saved[0].metadata.clientRequestId, "first-turn");
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: { messageId: saved[1].id } });
|
||||
assert.equal(fork.statusCode, 200);
|
||||
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
|
||||
assert.deepEqual(forkDetail.json().chat.messages.map((m: any) => m.metadata.generation), saved.map((m: any) => m.metadata.generation));
|
||||
assert.equal(forkDetail.json().chat.messages[0].metadata.clientRequestId, undefined);
|
||||
});
|
||||
|
||||
test("legacy call matches are projected without rewriting history and survive a new fork", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "Older reply", lastUsedProvider: "gemini", lastUsedModel: "different-current-model",
|
||||
messages: { create: { role: "assistant", content: "Older reply", metadata: { clientRequestId: "legacy-request", citations: ["https://example.test"] } } },
|
||||
calls: { create: { provider: "anthropic", model: "original-model", request: { clientRequestId: "legacy-request" }, latencyMs: 10 } },
|
||||
}, include: { messages: true } });
|
||||
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
|
||||
assert.deepEqual(detail.json().chat.messages[0].metadata.generation, { provider: "anthropic", model: "original-model" });
|
||||
assert.deepEqual((await prisma.message.findUniqueOrThrow({ where: { id: chat.messages[0]!.id } })).metadata, chat.messages[0]!.metadata);
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: {} });
|
||||
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
|
||||
assert.deepEqual(forkDetail.json().chat.messages[0].metadata, { citations: ["https://example.test"], generation: { provider: "anthropic", model: "original-model" } });
|
||||
});
|
||||
|
||||
test("missing, ambiguous, failed and unfinished calls never guess a reply's model", () => {
|
||||
const call = { provider: "openai", model: "model-one", request: { clientRequestId: "request" }, error: null, latencyMs: 10 };
|
||||
const message = { role: "assistant", metadata: { clientRequestId: "request" } };
|
||||
for (const calls of [[], [call, { ...call, provider: "xai" }], [{ ...call, error: "failed" }], [{ ...call, latencyMs: null }]]) {
|
||||
assert.deepEqual(withMessageGenerations([message], calls), [message]);
|
||||
}
|
||||
const existing = { ...message, metadata: { ...message.metadata, generation: { provider: "gemini", model: "original" } } };
|
||||
const aside = { ...message, metadata: { ...message.metadata, agentAside: true } };
|
||||
const user = { ...message, role: "user" };
|
||||
assert.deepEqual(withMessageGenerations([existing, aside, user], [call]), [existing, aside, user]);
|
||||
});
|
||||
Reference in New Issue
Block a user