打通多模型并行对比接口与采纳结果。
冻结消息上下文后并行重放多个配置,并顺带挂上定价接口路由。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
getApiProfile,
|
||||
listApiProfiles,
|
||||
profileToLlmConfig,
|
||||
} from "../config/api-profiles.js";
|
||||
import {
|
||||
OpenAiCompatibleProvider,
|
||||
type ChatMessage as LlmChatMessage,
|
||||
type ToolCallPayload,
|
||||
} from "../llm/client.js";
|
||||
import type { LlmContextTrace } from "../types/context-trace.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
|
||||
export type CompareCandidateStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
export type CompareCandidate = {
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
model: string;
|
||||
status: CompareCandidateStatus;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
error?: string;
|
||||
usage?: {
|
||||
totalTokens?: number;
|
||||
cachedTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
};
|
||||
finishedAt?: string;
|
||||
};
|
||||
|
||||
export type ModelCompareJob = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
createdAt: string;
|
||||
status: "running" | "done" | "error";
|
||||
/** 原消息正文,便于预览对照 */
|
||||
original: {
|
||||
text: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
};
|
||||
candidates: CompareCandidate[];
|
||||
};
|
||||
|
||||
const jobs = new Map<string, ModelCompareJob>();
|
||||
const JOB_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
function pruneJobs(): void {
|
||||
const cutoff = Date.now() - JOB_TTL_MS;
|
||||
for (const [id, job] of jobs) {
|
||||
if (Date.parse(job.createdAt) < cutoff) jobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 contextTrace 里冻住的条目还原成可发给 LLM 的 messages */
|
||||
export function reconstructLlmMessages(
|
||||
trace: LlmContextTrace,
|
||||
): LlmChatMessage[] {
|
||||
const out: LlmChatMessage[] = [];
|
||||
for (const item of trace.messages) {
|
||||
const role = String(item.role ?? "user");
|
||||
const raw = typeof item.content === "string" ? item.content : "";
|
||||
const restored = tryParseExtras(raw);
|
||||
if (restored) {
|
||||
out.push(restored);
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant") {
|
||||
out.push({ role: "assistant", content: raw || null });
|
||||
} else if (role === "tool") {
|
||||
out.push({ role: "tool", content: raw, tool_call_id: "unknown" });
|
||||
} else if (role === "system") {
|
||||
out.push({ role: "system", content: raw });
|
||||
} else {
|
||||
out.push({ role: "user", content: raw });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tryParseExtras(raw: string): LlmChatMessage | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("{")) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
role?: string;
|
||||
content?: string | null;
|
||||
tool_calls?: ToolCallPayload[];
|
||||
tool_call_id?: string;
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.role) return null;
|
||||
if (parsed.role === "assistant") {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: parsed.content ?? null,
|
||||
...(Array.isArray(parsed.tool_calls)
|
||||
? { tool_calls: parsed.tool_calls }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (parsed.role === "tool" && parsed.tool_call_id) {
|
||||
return {
|
||||
role: "tool",
|
||||
content: typeof parsed.content === "string" ? parsed.content : "",
|
||||
tool_call_id: parsed.tool_call_id,
|
||||
};
|
||||
}
|
||||
if (parsed.role === "system" || parsed.role === "user") {
|
||||
return {
|
||||
role: parsed.role,
|
||||
content: typeof parsed.content === "string" ? parsed.content : "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createCompareJob(input: {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
profileIds: string[];
|
||||
original: ModelCompareJob["original"];
|
||||
}): ModelCompareJob {
|
||||
pruneJobs();
|
||||
const uniqueIds = [...new Set(input.profileIds.map((id) => id.trim()).filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new Error("请至少选择一个 API 配置");
|
||||
}
|
||||
const candidates: CompareCandidate[] = uniqueIds.map((profileId) => {
|
||||
const profile = getApiProfile(profileId);
|
||||
if (!profile?.apiKey?.trim()) {
|
||||
throw new Error(`API 配置不可用:${profileId}`);
|
||||
}
|
||||
return {
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
model: profile.model,
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
const job: ModelCompareJob = {
|
||||
id: randomUUID(),
|
||||
sessionId: input.sessionId,
|
||||
messageId: input.messageId,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "running",
|
||||
original: input.original,
|
||||
candidates,
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getCompareJob(jobId: string): ModelCompareJob | null {
|
||||
pruneJobs();
|
||||
return jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
export function listProfilesForCompare(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
hasKey: boolean;
|
||||
}> {
|
||||
return listApiProfiles().map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
model: p.model,
|
||||
hasKey: Boolean(p.apiKey?.trim()),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 并行用各 profile 重放同一 contextTrace(不改会话) */
|
||||
export async function runCompareJob(
|
||||
job: ModelCompareJob,
|
||||
trace: LlmContextTrace,
|
||||
): Promise<ModelCompareJob> {
|
||||
const messages = reconstructLlmMessages(trace);
|
||||
if (messages.length === 0) {
|
||||
job.status = "error";
|
||||
for (const c of job.candidates) {
|
||||
c.status = "error";
|
||||
c.error = "上下文为空";
|
||||
c.finishedAt = new Date().toISOString();
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
const generation = trace.generation as GenerationParameters | undefined;
|
||||
await Promise.all(
|
||||
job.candidates.map(async (candidate) => {
|
||||
candidate.status = "running";
|
||||
try {
|
||||
const profile = getApiProfile(candidate.profileId);
|
||||
if (!profile?.apiKey?.trim()) {
|
||||
throw new Error("配置不存在或缺少 API Key");
|
||||
}
|
||||
const llm = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||
const result = await llm.complete(messages, {
|
||||
generation,
|
||||
caller: `model-compare:${candidate.profileId}`,
|
||||
});
|
||||
candidate.content = result.content ?? "";
|
||||
candidate.thinking = result.reasoning;
|
||||
candidate.usage = result.usage
|
||||
? {
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
}
|
||||
: undefined;
|
||||
if (result.model) candidate.model = result.model;
|
||||
candidate.status = "done";
|
||||
} catch (err) {
|
||||
candidate.status = "error";
|
||||
candidate.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
candidate.finishedAt = new Date().toISOString();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const anyDone = job.candidates.some((c) => c.status === "done");
|
||||
job.status = anyDone ? "done" : "error";
|
||||
return job;
|
||||
}
|
||||
|
||||
export function findCandidate(
|
||||
job: ModelCompareJob,
|
||||
profileId: string,
|
||||
): CompareCandidate | undefined {
|
||||
return job.candidates.find((c) => c.profileId === profileId);
|
||||
}
|
||||
@@ -70,6 +70,8 @@ const server = createServer(async (req, res) => {
|
||||
"GET /api/directors",
|
||||
"GET /api/modules",
|
||||
"GET /api/stats/tokens",
|
||||
"GET /api/stats/pricing",
|
||||
"POST /api/stats/pricing/sources",
|
||||
],
|
||||
});
|
||||
return;
|
||||
@@ -240,7 +242,7 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
const messageActionMatch = sub.match(
|
||||
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart)$/,
|
||||
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart|compare)$/,
|
||||
);
|
||||
if (req.method === "POST" && messageActionMatch) {
|
||||
const messageId = decodeURIComponent(messageActionMatch[1]);
|
||||
@@ -248,6 +250,7 @@ const server = createServer(async (req, res) => {
|
||||
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
|
||||
text?: string;
|
||||
direction?: string;
|
||||
profileIds?: string[];
|
||||
};
|
||||
let view;
|
||||
try {
|
||||
@@ -277,6 +280,17 @@ const server = createServer(async (req, res) => {
|
||||
view = await sessionManager.deleteMessage(sessionId, messageId);
|
||||
} else if (action === "restart") {
|
||||
view = await sessionManager.restartFromMessage(sessionId, messageId);
|
||||
} else if (action === "compare") {
|
||||
const profileIds = Array.isArray(body.profileIds)
|
||||
? body.profileIds.filter((x): x is string => typeof x === "string")
|
||||
: [];
|
||||
const job = await sessionManager.startModelCompare(
|
||||
sessionId,
|
||||
messageId,
|
||||
profileIds,
|
||||
);
|
||||
json(res, 200, job);
|
||||
return;
|
||||
} else {
|
||||
json(res, 400, { error: "未知 action" });
|
||||
return;
|
||||
@@ -291,6 +305,39 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const compareJobMatch = sub.match(/^\/compare\/([^/]+)(?:\/(adopt))?$/);
|
||||
if (compareJobMatch) {
|
||||
const jobId = decodeURIComponent(compareJobMatch[1]);
|
||||
const adopt = compareJobMatch[2] === "adopt";
|
||||
try {
|
||||
if (req.method === "GET" && !adopt) {
|
||||
json(res, 200, sessionManager.getModelCompare(sessionId, jobId));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && adopt) {
|
||||
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
|
||||
profileId?: string;
|
||||
};
|
||||
if (!body.profileId?.trim()) {
|
||||
json(res, 400, { error: "缺少 profileId" });
|
||||
return;
|
||||
}
|
||||
const view = await sessionManager.adoptModelCompare(
|
||||
sessionId,
|
||||
jobId,
|
||||
body.profileId.trim(),
|
||||
);
|
||||
json(res, 200, view);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "对比操作失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/lifecycle") {
|
||||
const body = JSON.parse(await readBody(req)) as { stage?: string };
|
||||
if (body.stage !== "design" && body.stage !== "play") {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconstructLlmMessages } from "../src/server/model-compare.js";
|
||||
import { buildContextTrace } from "../src/types/context-trace.js";
|
||||
|
||||
describe("model-compare reconstructLlmMessages", () => {
|
||||
it("restores plain roles", () => {
|
||||
const trace = buildContextTrace({
|
||||
caller: "test",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
model: "m1",
|
||||
});
|
||||
expect(reconstructLlmMessages(trace)).toEqual([
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "hi" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores tool_calls extras frozen as JSON content", () => {
|
||||
const trace = buildContextTrace({
|
||||
caller: "test",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "foo", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", content: "ok", tool_call_id: "c1" },
|
||||
],
|
||||
});
|
||||
const msgs = reconstructLlmMessages(trace);
|
||||
expect(msgs[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "foo", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(msgs[1]).toEqual({
|
||||
role: "tool",
|
||||
content: "ok",
|
||||
tool_call_id: "c1",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user