@@ -0,0 +1,36 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
|
||||
export type LlmRequestAuditEntry = {
|
||||
kind: "tool" | "skill";
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
target: string;
|
||||
reason: string;
|
||||
reasonProvided: boolean;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let logDirectoryReadyPromise: Promise<void> | null = null;
|
||||
|
||||
const ensureLogDirectory = async () => {
|
||||
if (!logDirectoryReadyPromise) {
|
||||
logDirectoryReadyPromise = mkdir(dirname(config.LLM_REQUEST_AUDIT_LOG_PATH), {
|
||||
recursive: true,
|
||||
}).then(() => undefined);
|
||||
}
|
||||
await logDirectoryReadyPromise;
|
||||
};
|
||||
|
||||
export const writeLlmRequestAuditLog = async (entry: LlmRequestAuditEntry) => {
|
||||
await ensureLogDirectory();
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry,
|
||||
});
|
||||
await appendFile(config.LLM_REQUEST_AUDIT_LOG_PATH, `${line}\n`, "utf8");
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const envSchema = z.object({
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
LLM_REQUEST_AUDIT_LOG_PATH: z.string().default("./logs/llm-request-audit.log"),
|
||||
AGENT_INTERNAL_TOKEN: z.string().optional(),
|
||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||
|
||||
const payloadSchema = z.object({
|
||||
message: z.string().min(1).max(10000),
|
||||
@@ -204,6 +205,8 @@ export const buildChatRouter = (
|
||||
opencodeSessionId: binding.sessionId,
|
||||
clientSessionId,
|
||||
message: parsed.data.message,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
write: (event, data) => {
|
||||
if (streamClosed || res.writableEnded || res.destroyed) {
|
||||
@@ -260,6 +263,42 @@ const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
const extractRequestReason = (params: Record<string, unknown>) => {
|
||||
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
|
||||
for (const key of candidates) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
|
||||
|
||||
const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
||||
const payload = isObjectRecord(event.properties)
|
||||
? (event.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const candidateName =
|
||||
typeof payload.skill === "string"
|
||||
? payload.skill
|
||||
: typeof payload.skillName === "string"
|
||||
? payload.skillName
|
||||
: typeof payload.name === "string"
|
||||
? payload.name
|
||||
: event.type;
|
||||
const reason = extractRequestReason(payload);
|
||||
return {
|
||||
name: candidateName,
|
||||
reason,
|
||||
payload,
|
||||
};
|
||||
};
|
||||
|
||||
const hasToolParams = (params: Record<string, unknown>) =>
|
||||
Object.keys(params).length > 0;
|
||||
|
||||
@@ -268,6 +307,8 @@ type StreamPromptOptions = {
|
||||
opencodeSessionId: string;
|
||||
clientSessionId: string;
|
||||
message: string;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
@@ -277,6 +318,8 @@ const streamPromptResponse = async ({
|
||||
opencodeSessionId,
|
||||
clientSessionId,
|
||||
message,
|
||||
traceId,
|
||||
projectId,
|
||||
signal,
|
||||
write,
|
||||
}: StreamPromptOptions) => {
|
||||
@@ -379,6 +422,23 @@ const streamPromptResponse = async ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSkillEvent(event)) {
|
||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "skill",
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: name,
|
||||
reason,
|
||||
reasonProvided: Boolean(reason),
|
||||
payload,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to write skill audit log");
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||
const partType = partTypes.get(event.properties.partID);
|
||||
if (partType === "text") {
|
||||
@@ -420,6 +480,7 @@ const streamPromptResponse = async ({
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
const toolParams = normalizeToolParams(part.state.input);
|
||||
const reason = extractRequestReason(toolParams);
|
||||
const isToolFinalState =
|
||||
part.state.status === "completed" || part.state.status === "error";
|
||||
|
||||
@@ -436,10 +497,34 @@ const streamPromptResponse = async ({
|
||||
(hasToolParams(toolParams) || isToolFinalState)
|
||||
) {
|
||||
emittedToolParts.add(part.id);
|
||||
if (!reason) {
|
||||
logger.warn(
|
||||
{
|
||||
tool: part.tool,
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
},
|
||||
"llm tool request missing reason",
|
||||
);
|
||||
}
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "tool",
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: part.tool,
|
||||
reason,
|
||||
reasonProvided: Boolean(reason),
|
||||
payload: toolParams,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to write tool audit log");
|
||||
});
|
||||
write("tool_call", {
|
||||
session_id: clientSessionId,
|
||||
tool: part.tool,
|
||||
params: toolParams,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
|
||||
const result = await dynamicHttpExecutor.execute(
|
||||
{
|
||||
reason: req.body?.reason,
|
||||
path: req.body?.path,
|
||||
method: req.body?.method,
|
||||
arguments: req.body?.arguments,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export type DynamicHttpInput = {
|
||||
reason?: string;
|
||||
path: string;
|
||||
method?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
@@ -65,6 +66,7 @@ export class DynamicHttpExecutor {
|
||||
{
|
||||
method,
|
||||
path,
|
||||
reason: typeof input.reason === "string" ? input.reason : undefined,
|
||||
statusCode: response.status,
|
||||
durationMs,
|
||||
traceId: context.traceId,
|
||||
|
||||
Reference in New Issue
Block a user