新增LLM追踪只读接口
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user