打通多模型并行对比接口与采纳结果。
冻结消息上下文后并行重放多个配置,并顺带挂上定价接口路由。 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/directors",
|
||||||
"GET /api/modules",
|
"GET /api/modules",
|
||||||
"GET /api/stats/tokens",
|
"GET /api/stats/tokens",
|
||||||
|
"GET /api/stats/pricing",
|
||||||
|
"POST /api/stats/pricing/sources",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -240,7 +242,7 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageActionMatch = sub.match(
|
const messageActionMatch = sub.match(
|
||||||
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart)$/,
|
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart|compare)$/,
|
||||||
);
|
);
|
||||||
if (req.method === "POST" && messageActionMatch) {
|
if (req.method === "POST" && messageActionMatch) {
|
||||||
const messageId = decodeURIComponent(messageActionMatch[1]);
|
const messageId = decodeURIComponent(messageActionMatch[1]);
|
||||||
@@ -248,6 +250,7 @@ const server = createServer(async (req, res) => {
|
|||||||
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
|
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
|
||||||
text?: string;
|
text?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
|
profileIds?: string[];
|
||||||
};
|
};
|
||||||
let view;
|
let view;
|
||||||
try {
|
try {
|
||||||
@@ -277,6 +280,17 @@ const server = createServer(async (req, res) => {
|
|||||||
view = await sessionManager.deleteMessage(sessionId, messageId);
|
view = await sessionManager.deleteMessage(sessionId, messageId);
|
||||||
} else if (action === "restart") {
|
} else if (action === "restart") {
|
||||||
view = await sessionManager.restartFromMessage(sessionId, messageId);
|
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 {
|
} else {
|
||||||
json(res, 400, { error: "未知 action" });
|
json(res, 400, { error: "未知 action" });
|
||||||
return;
|
return;
|
||||||
@@ -291,6 +305,39 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
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") {
|
if (req.method === "POST" && sub === "/lifecycle") {
|
||||||
const body = JSON.parse(await readBody(req)) as { stage?: string };
|
const body = JSON.parse(await readBody(req)) as { stage?: string };
|
||||||
if (body.stage !== "design" && body.stage !== "play") {
|
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