新增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