From 8a14ce8722de1e04f0eee41d6499eac4550248b8 Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Thu, 16 Jul 2026 12:25:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9ELLM=E8=BF=BD=E8=B8=AA?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/__tests__/llm-traces.test.ts | 210 ++++++++++++++++++ .../llm-trace/[callId]/request/route.ts | 17 ++ .../llm-trace/[callId]/response/route.ts | 17 ++ src/app/api/jobs/[jobId]/llm-trace/route.ts | 21 ++ src/app/api/llm-traces/latest/route.ts | 18 ++ src/lib/llm/trace-http.ts | 77 +++++++ 6 files changed, 360 insertions(+) create mode 100644 src/app/api/__tests__/llm-traces.test.ts create mode 100644 src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts create mode 100644 src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts create mode 100644 src/app/api/jobs/[jobId]/llm-trace/route.ts create mode 100644 src/app/api/llm-traces/latest/route.ts create mode 100644 src/lib/llm/trace-http.ts diff --git a/src/app/api/__tests__/llm-traces.test.ts b/src/app/api/__tests__/llm-traces.test.ts new file mode 100644 index 0000000..f2e74e5 --- /dev/null +++ b/src/app/api/__tests__/llm-traces.test.ts @@ -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>(values: T) { + return { params: Promise.resolve(values) }; +} diff --git a/src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts b/src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts new file mode 100644 index 0000000..e3bf5d5 --- /dev/null +++ b/src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts @@ -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" }); +} diff --git a/src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts b/src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts new file mode 100644 index 0000000..aa2cf59 --- /dev/null +++ b/src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts @@ -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" }); +} diff --git a/src/app/api/jobs/[jobId]/llm-trace/route.ts b/src/app/api/jobs/[jobId]/llm-trace/route.ts new file mode 100644 index 0000000..df59a6a --- /dev/null +++ b/src/app/api/jobs/[jobId]/llm-trace/route.ts @@ -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); +} diff --git a/src/app/api/llm-traces/latest/route.ts b/src/app/api/llm-traces/latest/route.ts new file mode 100644 index 0000000..433e944 --- /dev/null +++ b/src/app/api/llm-traces/latest/route.ts @@ -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) }); +} diff --git a/src/lib/llm/trace-http.ts b/src/lib/llm/trace-http.ts new file mode 100644 index 0000000..9a9f8ca --- /dev/null +++ b/src/lib/llm/trace-http.ts @@ -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(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 { + 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); +}