新增LLM追踪只读接口

This commit is contained in:
czj
2026-07-16 12:25:28 +08:00
parent 8e50ecef77
commit 8a14ce8722
6 changed files with 360 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
import { createLocalTracePayloadStore } from "../../../lib/llm/trace-payload-store";
import { createSqliteTraceRepository } from "../../../lib/llm/sqlite-trace-repository";
import type {
LlmTraceCall,
LlmTraceManifest,
LlmTraceRun,
} from "../../../lib/llm/trace-types";
import { GET as getLatestTrace } from "../llm-traces/latest/route";
import { GET as getJobTrace } from "../jobs/[jobId]/llm-trace/route";
import { GET as getTraceRequest } from "../jobs/[jobId]/llm-trace/[callId]/request/route";
import { GET as getTraceResponse } from "../jobs/[jobId]/llm-trace/[callId]/response/route";
const exactRequestFixture = {
model: "deepseek-v4-pro",
temperature: 0.1,
response_format: { type: "json_object" },
messages: [{ role: "user", content: "完整文章正文" }],
};
const exactResponseFixture = {
id: "chatcmpl_1",
choices: [{ message: { content: '{"ok":true}' }, finish_reason: "stop" }],
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
};
describe("LLM trace read APIs", () => {
let tempDir: string;
let jobId: string;
const originalDataDir = process.env.APP_DATA_DIR;
const originalApiKey = process.env.API_ACCESS_KEY;
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
const originalRuntime = process.env.APP_RUNTIME;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "geo-llm-trace-api-"));
process.env.APP_DATA_DIR = tempDir;
process.env.API_ACCESS_KEY = "test-key";
process.env.API_AUTH_DISABLED = "false";
delete process.env.APP_RUNTIME;
const appRepository = createSqliteRepository();
const job = await appRepository.createArticleJob({
source_title: "",
source_body: "完整文章正文",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
jobId = job.id;
const traceRepository = createSqliteTraceRepository();
const run: LlmTraceRun = {
job_id: jobId,
case_id: null,
status: "running",
current_stage: "draft",
trace_completeness: "complete",
error_stage: null,
error_summary: null,
started_at: "2026-07-16T00:00:00.000Z",
finished_at: null,
updated_at: "2026-07-16T00:00:01.000Z",
};
const call: LlmTraceCall = {
call_id: "llmcall_1",
job_id: jobId,
sequence: 1,
task: "article_optimizer",
workflow_stage: "draft",
rewrite_round: null,
provider: "deepseek",
model: "deepseek-v4-pro",
status: "validated",
request_object_key: `llm-traces/${jobId}/llmcall_1/request.json`,
response_object_key: `llm-traces/${jobId}/llmcall_1/response.json`,
token_usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
schema_name: "optimizedArticleSchema",
schema_valid: true,
validation_issues: [],
business_status: null,
duration_ms: 1200,
started_at: "2026-07-16T00:00:00.000Z",
responded_at: "2026-07-16T00:00:01.000Z",
validated_at: "2026-07-16T00:00:01.200Z",
failed_at: null,
error_type: null,
error_summary: null,
};
await traceRepository.putRun(run);
await traceRepository.putCall(call);
const payloadStore = createLocalTracePayloadStore();
await payloadStore.putJson(call.request_object_key!, exactRequestFixture);
await payloadStore.putJson(call.response_object_key!, exactResponseFixture);
});
afterEach(() => {
process.env.APP_DATA_DIR = originalDataDir;
process.env.API_ACCESS_KEY = originalApiKey;
process.env.API_AUTH_DISABLED = originalAuthDisabled;
process.env.APP_RUNTIME = originalRuntime;
rmSync(tempDir, { recursive: true, force: true });
});
it("rejects trace reads without API access", async () => {
const response = await getLatestTrace(request(null));
expect(response.status).toBe(401);
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("returns a no-store latest manifest without raw bodies or object keys", async () => {
const response = await getLatestTrace(request("test-key"));
const body = await response.json() as LlmTraceManifest;
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.run.job_id).toBe(jobId);
expect(body.calls[0]).toMatchObject({
call_id: "llmcall_1",
request_available: true,
response_available: true,
});
expect(body.calls[0]).not.toHaveProperty("request_object_key");
expect(body.calls[0]).not.toHaveProperty("response_object_key");
expect(JSON.stringify(body)).not.toContain("messages");
expect(JSON.stringify(body)).not.toContain("choices");
});
it("returns the same protected manifest for a specific job", async () => {
const response = await getJobTrace(
request("test-key"),
params({ jobId }),
);
const body = await response.json() as LlmTraceManifest;
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.run.job_id).toBe(jobId);
expect(body.calls).toHaveLength(1);
});
it("returns the exact stored request and response through protected routes", async () => {
const requestResponse = await getTraceRequest(
request("test-key"),
params({ jobId, callId: "llmcall_1" }),
);
const responseResponse = await getTraceResponse(
request("test-key"),
params({ jobId, callId: "llmcall_1" }),
);
expect(requestResponse.headers.get("cache-control")).toBe("no-store");
await expect(requestResponse.json()).resolves.toEqual(exactRequestFixture);
await expect(responseResponse.json()).resolves.toEqual(exactResponseFixture);
});
it("returns clear waiting and unavailable states when a response is absent", async () => {
const repository = createSqliteTraceRepository();
const [existing] = await repository.listCalls(jobId);
await repository.putCall({
...existing,
call_id: "llmcall_waiting",
sequence: 2,
status: "started",
response_object_key: null,
});
await repository.putCall({
...existing,
call_id: "llmcall_failed",
sequence: 3,
status: "failed",
response_object_key: null,
failed_at: "2026-07-16T00:00:02.000Z",
error_type: "provider",
error_summary: "Error: timeout",
});
const waiting = await getTraceResponse(
request("test-key"),
params({ jobId, callId: "llmcall_waiting" }),
);
const unavailable = await getTraceResponse(
request("test-key"),
params({ jobId, callId: "llmcall_failed" }),
);
expect(waiting.status).toBe(202);
await expect(waiting.json()).resolves.toEqual({ state: "waiting" });
expect(unavailable.status).toBe(404);
await expect(unavailable.json()).resolves.toEqual({
error: "该调用未产生响应",
});
});
});
function request(apiKey: string | null) {
return new Request("http://localhost/api/llm-traces/latest", {
headers: apiKey ? { "x-api-key": apiKey } : undefined,
});
}
function params<T extends Record<string, string>>(values: T) {
return { params: Promise.resolve(values) };
}
@@ -0,0 +1,17 @@
import { requireApiAccess } from "../../../../../../../lib/api/auth";
import {
noStoreResponse,
readTracePayload,
} from "../../../../../../../lib/llm/trace-http";
interface RouteContext {
params: Promise<{ jobId: string; callId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) return noStoreResponse(access.response);
const { jobId, callId } = await context.params;
return readTracePayload({ jobId, callId, kind: "request" });
}
@@ -0,0 +1,17 @@
import { requireApiAccess } from "../../../../../../../lib/api/auth";
import {
noStoreResponse,
readTracePayload,
} from "../../../../../../../lib/llm/trace-http";
interface RouteContext {
params: Promise<{ jobId: string; callId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) return noStoreResponse(access.response);
const { jobId, callId } = await context.params;
return readTracePayload({ jobId, callId, kind: "response" });
}
@@ -0,0 +1,21 @@
import { requireApiAccess } from "../../../../../lib/api/auth";
import {
getTraceManifest,
noStoreJson,
noStoreResponse,
} from "../../../../../lib/llm/trace-http";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) return noStoreResponse(access.response);
const { jobId } = await context.params;
const manifest = await getTraceManifest(jobId);
return manifest
? noStoreJson(manifest)
: noStoreJson({ error: "追踪任务不存在" }, 404);
}
+18
View File
@@ -0,0 +1,18 @@
import { requireApiAccess } from "../../../../lib/api/auth";
import {
noStoreJson,
noStoreResponse,
toPublicTraceCall,
} from "../../../../lib/llm/trace-http";
import { getLlmTraceRepositoryFromRuntime } from "../../../../lib/llm/trace-repository";
export async function GET(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) return noStoreResponse(access.response);
const repository = getLlmTraceRepositoryFromRuntime();
const run = await repository.getLatestRun();
if (!run) return noStoreJson({ run: null, calls: [] });
const calls = await repository.listCalls(run.job_id);
return noStoreJson({ run, calls: calls.map(toPublicTraceCall) });
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getLlmTracePayloadStoreFromRuntime } from "./trace-payload-store";
import { getLlmTraceRepositoryFromRuntime } from "./trace-repository";
import type {
LlmTraceCall,
LlmTraceCallPublic,
LlmTraceManifest,
} from "./trace-types";
export function noStoreJson(body: unknown, status = 200) {
return NextResponse.json(body, {
status,
headers: { "cache-control": "no-store" },
});
}
export function noStoreResponse<T extends Response>(response: T): T {
response.headers.set("cache-control", "no-store");
return response;
}
export function toPublicTraceCall(call: LlmTraceCall): LlmTraceCallPublic {
const {
request_object_key: requestObjectKey,
response_object_key: responseObjectKey,
...publicFields
} = call;
return {
...publicFields,
request_available: Boolean(requestObjectKey),
response_available: Boolean(responseObjectKey),
};
}
export async function getTraceManifest(
jobId: string,
): Promise<LlmTraceManifest | null> {
const repository = getLlmTraceRepositoryFromRuntime();
const run = await repository.getRun(jobId);
if (!run) return null;
const calls = await repository.listCalls(jobId);
return { run, calls: calls.map(toPublicTraceCall) };
}
export async function readTracePayload({
jobId,
callId,
kind,
}: {
jobId: string;
callId: string;
kind: "request" | "response";
}) {
const repository = getLlmTraceRepositoryFromRuntime();
const call = (await repository.listCalls(jobId)).find(
(candidate) => candidate.call_id === callId,
);
if (!call) return noStoreJson({ error: "追踪调用不存在" }, 404);
const key = kind === "request"
? call.request_object_key
: call.response_object_key;
if (!key) {
if (kind === "response" && call.status === "started") {
return noStoreJson({ state: "waiting" }, 202);
}
return noStoreJson({
error: kind === "response" ? "该调用未产生响应" : "追踪请求正文不存在",
}, 404);
}
const payload = await getLlmTracePayloadStoreFromRuntime().getJson(key);
return payload == null
? noStoreJson({ error: "追踪正文不存在" }, 404)
: noStoreJson(payload);
}