@jpssff/vanor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-cn.md +166 -0
- package/README.md +120 -0
- package/base/config.js +162 -0
- package/base/core/compaction.js +58 -0
- package/base/core/harness.js +246 -0
- package/base/core/loop.js +72 -0
- package/base/core/prompt.js +126 -0
- package/base/core/session.js +255 -0
- package/base/events.js +54 -0
- package/base/i18n/index.js +80 -0
- package/base/i18n/locales/en.js +254 -0
- package/base/i18n/locales/zh-CN.js +252 -0
- package/base/llm/index.js +119 -0
- package/base/llm/providers/anthropic.js +147 -0
- package/base/llm/providers/openai.js +155 -0
- package/base/llm/sse.js +27 -0
- package/base/llm/trace.js +64 -0
- package/base/logger.js +57 -0
- package/base/memory/index.js +139 -0
- package/base/security/index.js +77 -0
- package/base/skills/loader.js +297 -0
- package/base/test/cli.test.js +91 -0
- package/base/test/config.test.js +63 -0
- package/base/test/core.test.js +154 -0
- package/base/test/i18n.test.js +32 -0
- package/base/test/loop.test.js +97 -0
- package/base/test/memory.test.js +47 -0
- package/base/test/message.test.js +38 -0
- package/base/test/session.test.js +324 -0
- package/base/test/skills.test.js +236 -0
- package/base/test/statusbar.test.js +143 -0
- package/base/test/tools.test.js +127 -0
- package/base/test/trace.test.js +62 -0
- package/base/test/tui.test.js +242 -0
- package/base/test/utils.test.js +35 -0
- package/base/tools/builtin.js +221 -0
- package/base/tools/index.js +157 -0
- package/base/transport/cli.js +417 -0
- package/base/transport/message.js +81 -0
- package/base/transport/statusbar.js +117 -0
- package/base/transport/tui.js +397 -0
- package/base/utils.js +150 -0
- package/docs/TECH_DESIGN.md +544 -0
- package/index.js +175 -0
- package/package.json +33 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// LLM 统一入口:模型解析、流式调用、重试与跨模型 failover。
|
|
2
|
+
|
|
3
|
+
import { EVENTS } from "../events.js";
|
|
4
|
+
import { sleep } from "../utils.js";
|
|
5
|
+
import { streamOpenAI } from "./providers/openai.js";
|
|
6
|
+
import { streamAnthropic } from "./providers/anthropic.js";
|
|
7
|
+
import { createTracer } from "./trace.js";
|
|
8
|
+
|
|
9
|
+
const PROVIDER_FNS = {
|
|
10
|
+
openai: streamOpenAI,
|
|
11
|
+
anthropic: streamAnthropic,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 解析 "<providerName>/<modelId...>"。providerName 取第一个 "/" 之前,
|
|
16
|
+
* 其余为 modelId(允许包含 "/",如 deepseek/deepseek-v4-pro)。
|
|
17
|
+
*/
|
|
18
|
+
export function resolveModel(config, modelStr) {
|
|
19
|
+
const str = modelStr || config.llm.defaultModel;
|
|
20
|
+
if (!str) throw new Error("未指定模型,且 llm.defaultModel 为空");
|
|
21
|
+
const slash = str.indexOf("/");
|
|
22
|
+
if (slash < 0) throw new Error(`模型名需为 "<provider>/<model>" 形式:${str}`);
|
|
23
|
+
const providerName = str.slice(0, slash);
|
|
24
|
+
const modelId = str.slice(slash + 1);
|
|
25
|
+
const conf = config.llm.providers?.[providerName];
|
|
26
|
+
if (!conf) throw new Error(`未找到 provider "${providerName}",请检查 llm.providers 配置`);
|
|
27
|
+
const type = conf.type || "openai";
|
|
28
|
+
const fn = PROVIDER_FNS[type];
|
|
29
|
+
if (!fn) throw new Error(`不支持的 provider 类型 "${type}"`);
|
|
30
|
+
return { providerName, modelId, type, conf, fn };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function backoff(attempt) {
|
|
34
|
+
return Math.min(8000, 300 * 2 ** attempt) + Math.floor(Math.random() * 200);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createLLM(config, logger) {
|
|
38
|
+
const maxRetries = 2;
|
|
39
|
+
const tracer = createTracer(config, logger);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 流式调用。req: { model?, messages, tools?, signal? }
|
|
43
|
+
* 产出 LLMChunk。失败时按模型列表 failover(仅在尚未产出任何 chunk 时)。
|
|
44
|
+
*/
|
|
45
|
+
async function* stream(req) {
|
|
46
|
+
const candidates = [req.model || config.llm.defaultModel, ...(config.llm.fallback || [])]
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
let lastErr;
|
|
49
|
+
|
|
50
|
+
for (const modelStr of candidates) {
|
|
51
|
+
let resolved;
|
|
52
|
+
try {
|
|
53
|
+
resolved = resolveModel(config, modelStr);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
lastErr = e;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
60
|
+
let produced = false;
|
|
61
|
+
try {
|
|
62
|
+
logger.info(EVENTS.llm.request, { model: modelStr, attempt });
|
|
63
|
+
const iter = resolved.fn({
|
|
64
|
+
baseURL: resolved.conf.baseURL,
|
|
65
|
+
apiKey: resolved.conf.apiKey,
|
|
66
|
+
model: resolved.modelId,
|
|
67
|
+
messages: req.messages,
|
|
68
|
+
tools: req.tools,
|
|
69
|
+
signal: req.signal,
|
|
70
|
+
trace: tracer,
|
|
71
|
+
providerName: resolved.providerName,
|
|
72
|
+
});
|
|
73
|
+
for await (const chunk of iter) {
|
|
74
|
+
produced = true;
|
|
75
|
+
yield chunk;
|
|
76
|
+
}
|
|
77
|
+
logger.info(EVENTS.llm.response, { model: modelStr });
|
|
78
|
+
return; // 成功
|
|
79
|
+
} catch (e) {
|
|
80
|
+
lastErr = e;
|
|
81
|
+
if (e.name === "AbortError") throw e;
|
|
82
|
+
logger.warn(EVENTS.llm.error, {
|
|
83
|
+
model: modelStr,
|
|
84
|
+
attempt,
|
|
85
|
+
retriable: !!e.retriable,
|
|
86
|
+
produced,
|
|
87
|
+
error: e.message,
|
|
88
|
+
});
|
|
89
|
+
// 已产出部分内容:无法安全重放,交由上层处理
|
|
90
|
+
if (produced) throw e;
|
|
91
|
+
if (e.retriable && attempt < maxRetries) {
|
|
92
|
+
logger.info(EVENTS.llm.retry, { model: modelStr, attempt });
|
|
93
|
+
await sleep(backoff(attempt));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
break; // 不可重试或已耗尽 → 尝试下一个候选模型
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw lastErr || new Error("LLM 调用失败");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 便捷:消费整个流,返回 { text, toolCalls, usage }。 */
|
|
104
|
+
async function gather(req) {
|
|
105
|
+
let text = "";
|
|
106
|
+
const toolCalls = [];
|
|
107
|
+
let usage = null;
|
|
108
|
+
let finishReason = "stop";
|
|
109
|
+
for await (const chunk of stream(req)) {
|
|
110
|
+
if (chunk.type === "text") text += chunk.delta;
|
|
111
|
+
else if (chunk.type === "tool_call") toolCalls.push(chunk.call);
|
|
112
|
+
else if (chunk.type === "usage") usage = chunk.usage;
|
|
113
|
+
else if (chunk.type === "done") finishReason = chunk.finishReason;
|
|
114
|
+
}
|
|
115
|
+
return { text, toolCalls, usage, finishReason };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { stream, gather, resolveModel: (m) => resolveModel(config, m) };
|
|
119
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Anthropic Messages API 适配。
|
|
2
|
+
|
|
3
|
+
import { readLines } from "../sse.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE = "https://api.anthropic.com";
|
|
6
|
+
|
|
7
|
+
function toAnthropic(messages) {
|
|
8
|
+
let system = "";
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const m of messages) {
|
|
11
|
+
if (m.role === "system") {
|
|
12
|
+
system += (system ? "\n\n" : "") + (m.content || "");
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (m.role === "tool") {
|
|
16
|
+
out.push({
|
|
17
|
+
role: "user",
|
|
18
|
+
content: [
|
|
19
|
+
{ type: "tool_result", tool_use_id: m.toolCallId, content: m.content || "" },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (m.role === "assistant" && m.toolCalls?.length) {
|
|
25
|
+
const blocks = [];
|
|
26
|
+
if (m.content) blocks.push({ type: "text", text: m.content });
|
|
27
|
+
for (const tc of m.toolCalls) {
|
|
28
|
+
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments ?? {} });
|
|
29
|
+
}
|
|
30
|
+
out.push({ role: "assistant", content: blocks });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
out.push({ role: m.role, content: m.content || "" });
|
|
34
|
+
}
|
|
35
|
+
return { system, messages: out };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toAnthropicTools(tools) {
|
|
39
|
+
return tools.map((t) => ({
|
|
40
|
+
name: t.name,
|
|
41
|
+
description: t.description,
|
|
42
|
+
input_schema: t.parameters,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function* streamAnthropic({ baseURL, apiKey, model, messages, tools, signal, trace, providerName }) {
|
|
47
|
+
const url = `${(baseURL || DEFAULT_BASE).replace(/\/$/, "")}/v1/messages`;
|
|
48
|
+
const { system, messages: msgs } = toAnthropic(messages);
|
|
49
|
+
const body = {
|
|
50
|
+
model,
|
|
51
|
+
max_tokens: 4096,
|
|
52
|
+
stream: true,
|
|
53
|
+
messages: msgs,
|
|
54
|
+
};
|
|
55
|
+
if (system) body.system = system;
|
|
56
|
+
if (tools?.length) body.tools = toAnthropicTools(tools);
|
|
57
|
+
|
|
58
|
+
const headers = {
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"x-api-key": apiKey,
|
|
61
|
+
"anthropic-version": "2023-06-01",
|
|
62
|
+
};
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
trace?.request({ provider: providerName, model, url, headers, body });
|
|
65
|
+
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.name === "AbortError") throw e;
|
|
71
|
+
trace?.response({ provider: providerName, model, ms: Date.now() - started, error: `网络错误: ${e.message}` });
|
|
72
|
+
throw Object.assign(new Error(`网络错误: ${e.message}`), { retriable: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const text = await res.text().catch(() => "");
|
|
77
|
+
trace?.response({ provider: providerName, model, status: res.status, ms: Date.now() - started, error: text.slice(0, 2000) });
|
|
78
|
+
const err = new Error(`Anthropic 接口 HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
79
|
+
err.retriable = res.status === 429 || res.status === 408 || res.status >= 500;
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const toolBlocks = new Map(); // index → { id, name, json }
|
|
84
|
+
let finishReason = "stop";
|
|
85
|
+
let fullText = "";
|
|
86
|
+
let lastUsage = null;
|
|
87
|
+
|
|
88
|
+
for await (const line of readLines(res)) {
|
|
89
|
+
if (!line.startsWith("data:")) continue;
|
|
90
|
+
const data = line.slice(5).trim();
|
|
91
|
+
if (!data) continue;
|
|
92
|
+
let ev;
|
|
93
|
+
try {
|
|
94
|
+
ev = JSON.parse(data);
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (ev.type === "content_block_start" && ev.content_block?.type === "tool_use") {
|
|
100
|
+
toolBlocks.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, json: "" });
|
|
101
|
+
} else if (ev.type === "content_block_delta") {
|
|
102
|
+
if (ev.delta?.type === "text_delta") {
|
|
103
|
+
if (trace?.enabled) fullText += ev.delta.text;
|
|
104
|
+
yield { type: "text", delta: ev.delta.text };
|
|
105
|
+
} else if (ev.delta?.type === "input_json_delta") {
|
|
106
|
+
const acc = toolBlocks.get(ev.index);
|
|
107
|
+
if (acc) acc.json += ev.delta.partial_json || "";
|
|
108
|
+
}
|
|
109
|
+
} else if (ev.type === "message_delta") {
|
|
110
|
+
if (ev.usage) {
|
|
111
|
+
lastUsage = {
|
|
112
|
+
promptTokens: ev.usage.input_tokens ?? 0,
|
|
113
|
+
completionTokens: ev.usage.output_tokens ?? 0,
|
|
114
|
+
totalTokens: (ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
|
|
115
|
+
};
|
|
116
|
+
yield { type: "usage", usage: lastUsage };
|
|
117
|
+
}
|
|
118
|
+
if (ev.delta?.stop_reason) finishReason = ev.delta.stop_reason;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const calls = [];
|
|
123
|
+
for (const acc of toolBlocks.values()) {
|
|
124
|
+
let parsed = {};
|
|
125
|
+
try {
|
|
126
|
+
parsed = acc.json ? JSON.parse(acc.json) : {};
|
|
127
|
+
} catch {
|
|
128
|
+
parsed = { __raw: acc.json };
|
|
129
|
+
}
|
|
130
|
+
const call = { id: acc.id, name: acc.name, arguments: parsed };
|
|
131
|
+
calls.push(call);
|
|
132
|
+
yield { type: "tool_call", call };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
trace?.response({
|
|
136
|
+
provider: providerName,
|
|
137
|
+
model,
|
|
138
|
+
status: res.status,
|
|
139
|
+
ms: Date.now() - started,
|
|
140
|
+
text: fullText,
|
|
141
|
+
toolCalls: calls,
|
|
142
|
+
usage: lastUsage,
|
|
143
|
+
finishReason,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
yield { type: "done", finishReason };
|
|
147
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// OpenAI Chat Completions 兼容适配(OpenAI、兼容端点、多数国产/开源厂商)。
|
|
2
|
+
|
|
3
|
+
import { readLines } from "../sse.js";
|
|
4
|
+
|
|
5
|
+
function toOpenAIMessages(messages) {
|
|
6
|
+
return messages.map((m) => {
|
|
7
|
+
if (m.role === "assistant") {
|
|
8
|
+
const out = { role: "assistant", content: m.content || "" };
|
|
9
|
+
if (m.toolCalls?.length) {
|
|
10
|
+
out.content = m.content || null;
|
|
11
|
+
out.tool_calls = m.toolCalls.map((tc) => ({
|
|
12
|
+
id: tc.id,
|
|
13
|
+
type: "function",
|
|
14
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) },
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
if (m.role === "tool") {
|
|
20
|
+
return { role: "tool", tool_call_id: m.toolCallId, content: m.content || "" };
|
|
21
|
+
}
|
|
22
|
+
return { role: m.role, content: m.content || "" };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toOpenAITools(tools) {
|
|
27
|
+
return tools.map((t) => ({
|
|
28
|
+
type: "function",
|
|
29
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function httpError(status, body) {
|
|
34
|
+
const err = new Error(`OpenAI 接口 HTTP ${status}: ${String(body).slice(0, 300)}`);
|
|
35
|
+
// 429 / 5xx / 408 视为可重试
|
|
36
|
+
err.retriable = status === 429 || status === 408 || status >= 500;
|
|
37
|
+
err.status = status;
|
|
38
|
+
return err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 流式调用,产出 LLMChunk:
|
|
43
|
+
* { type:"text", delta } | { type:"tool_call", call } | { type:"usage", usage } | { type:"done", finishReason }
|
|
44
|
+
*/
|
|
45
|
+
export async function* streamOpenAI({ baseURL, apiKey, model, messages, tools, signal, trace, providerName }) {
|
|
46
|
+
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`;
|
|
47
|
+
const body = {
|
|
48
|
+
model,
|
|
49
|
+
messages: toOpenAIMessages(messages),
|
|
50
|
+
stream: true,
|
|
51
|
+
stream_options: { include_usage: true },
|
|
52
|
+
};
|
|
53
|
+
if (tools?.length) {
|
|
54
|
+
body.tools = toOpenAITools(tools);
|
|
55
|
+
body.tool_choice = "auto";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const headers = {
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
Authorization: `Bearer ${apiKey}`,
|
|
61
|
+
};
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
trace?.request({ provider: providerName, model, url, headers, body });
|
|
64
|
+
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e.name === "AbortError") throw e;
|
|
70
|
+
trace?.response({ provider: providerName, model, ms: Date.now() - started, error: `网络错误: ${e.message}` });
|
|
71
|
+
throw Object.assign(new Error(`网络错误: ${e.message}`), { retriable: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const text = await res.text().catch(() => "");
|
|
76
|
+
trace?.response({ provider: providerName, model, status: res.status, ms: Date.now() - started, error: text.slice(0, 2000) });
|
|
77
|
+
throw httpError(res.status, text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 累积 tool_calls:按 index 收集 id/name/arguments(字符串拼接)
|
|
81
|
+
const toolAcc = new Map();
|
|
82
|
+
let finishReason = null;
|
|
83
|
+
let fullText = "";
|
|
84
|
+
let lastUsage = null;
|
|
85
|
+
|
|
86
|
+
for await (const line of readLines(res)) {
|
|
87
|
+
if (!line.startsWith("data:")) continue;
|
|
88
|
+
const data = line.slice(5).trim();
|
|
89
|
+
if (data === "[DONE]") break;
|
|
90
|
+
let json;
|
|
91
|
+
try {
|
|
92
|
+
json = JSON.parse(data);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (json.usage) {
|
|
98
|
+
lastUsage = {
|
|
99
|
+
promptTokens: json.usage.prompt_tokens ?? 0,
|
|
100
|
+
completionTokens: json.usage.completion_tokens ?? 0,
|
|
101
|
+
totalTokens: json.usage.total_tokens ?? 0,
|
|
102
|
+
};
|
|
103
|
+
yield { type: "usage", usage: lastUsage };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const choice = json.choices?.[0];
|
|
107
|
+
if (!choice) continue;
|
|
108
|
+
const delta = choice.delta || {};
|
|
109
|
+
|
|
110
|
+
if (delta.content) {
|
|
111
|
+
if (trace?.enabled) fullText += delta.content;
|
|
112
|
+
yield { type: "text", delta: delta.content };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (delta.tool_calls) {
|
|
116
|
+
for (const tc of delta.tool_calls) {
|
|
117
|
+
const idx = tc.index ?? 0;
|
|
118
|
+
const acc = toolAcc.get(idx) || { id: tc.id, name: "", args: "" };
|
|
119
|
+
if (tc.id) acc.id = tc.id;
|
|
120
|
+
if (tc.function?.name) acc.name = tc.function.name;
|
|
121
|
+
if (tc.function?.arguments) acc.args += tc.function.arguments;
|
|
122
|
+
toolAcc.set(idx, acc);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 流结束后产出完整 tool_call
|
|
130
|
+
const calls = [];
|
|
131
|
+
for (const acc of toolAcc.values()) {
|
|
132
|
+
let parsed = {};
|
|
133
|
+
try {
|
|
134
|
+
parsed = acc.args ? JSON.parse(acc.args) : {};
|
|
135
|
+
} catch {
|
|
136
|
+
parsed = { __raw: acc.args };
|
|
137
|
+
}
|
|
138
|
+
const call = { id: acc.id || `call_${acc.name}`, name: acc.name, arguments: parsed };
|
|
139
|
+
calls.push(call);
|
|
140
|
+
yield { type: "tool_call", call };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
trace?.response({
|
|
144
|
+
provider: providerName,
|
|
145
|
+
model,
|
|
146
|
+
status: res.status,
|
|
147
|
+
ms: Date.now() - started,
|
|
148
|
+
text: fullText,
|
|
149
|
+
toolCalls: calls,
|
|
150
|
+
usage: lastUsage,
|
|
151
|
+
finishReason: finishReason || "stop",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
yield { type: "done", finishReason: finishReason || "stop" };
|
|
155
|
+
}
|
package/base/llm/sse.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// 原生 SSE / 流式行读取,零依赖。
|
|
2
|
+
|
|
3
|
+
/** 逐行读取 fetch Response 的流式 body(已去除行尾 \r)。 */
|
|
4
|
+
export async function* readLines(response) {
|
|
5
|
+
const reader = response.body.getReader();
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
let buf = "";
|
|
8
|
+
try {
|
|
9
|
+
while (true) {
|
|
10
|
+
const { done, value } = await reader.read();
|
|
11
|
+
if (done) break;
|
|
12
|
+
buf += decoder.decode(value, { stream: true });
|
|
13
|
+
let idx;
|
|
14
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
15
|
+
yield buf.slice(0, idx).replace(/\r$/, "");
|
|
16
|
+
buf = buf.slice(idx + 1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} finally {
|
|
20
|
+
try {
|
|
21
|
+
reader.releaseLock();
|
|
22
|
+
} catch {
|
|
23
|
+
// 忽略
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (buf.trim()) yield buf.replace(/\r$/, "");
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// LLM 完整请求/响应追踪。默认关闭;开启后将详情写入 JSONL 日志。
|
|
2
|
+
|
|
3
|
+
import { EVENTS } from "../events.js";
|
|
4
|
+
|
|
5
|
+
// 需要脱敏的 header(小写比较)
|
|
6
|
+
const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie"];
|
|
7
|
+
|
|
8
|
+
/** 将敏感值掩码为 "前8位…后4位",过短则整体打码。 */
|
|
9
|
+
export function maskValue(v) {
|
|
10
|
+
const s = String(v ?? "");
|
|
11
|
+
if (s.length <= 12) return "***";
|
|
12
|
+
return `${s.slice(0, 8)}…${s.slice(-4)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redactHeaders(headers, redact) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
18
|
+
out[k] = redact && SENSITIVE_HEADERS.includes(k.toLowerCase()) ? maskValue(v) : v;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 构造追踪器。
|
|
25
|
+
* @param {object} config 全量配置(读取 config.logging)
|
|
26
|
+
* @param {import('../logger.js').Logger} logger
|
|
27
|
+
*/
|
|
28
|
+
export function createTracer(config, logger) {
|
|
29
|
+
const cfg = config.logging || {};
|
|
30
|
+
const enabled = !!cfg.llmTrace;
|
|
31
|
+
const redact = cfg.llmTraceRedact !== false; // 默认脱敏
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
enabled,
|
|
35
|
+
|
|
36
|
+
/** 记录请求详情。 */
|
|
37
|
+
request({ provider, model, url, headers, body }) {
|
|
38
|
+
if (!enabled) return;
|
|
39
|
+
logger.info(EVENTS.llm.requestDetail, {
|
|
40
|
+
provider,
|
|
41
|
+
model,
|
|
42
|
+
url,
|
|
43
|
+
headers: redactHeaders(headers, redact),
|
|
44
|
+
body,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/** 记录响应详情(聚合后的返回内容 / 错误)。 */
|
|
49
|
+
response({ provider, model, status, ms, text, toolCalls, usage, finishReason, error }) {
|
|
50
|
+
if (!enabled) return;
|
|
51
|
+
logger.info(EVENTS.llm.responseDetail, {
|
|
52
|
+
provider,
|
|
53
|
+
model,
|
|
54
|
+
status,
|
|
55
|
+
ms,
|
|
56
|
+
finishReason,
|
|
57
|
+
text,
|
|
58
|
+
toolCalls,
|
|
59
|
+
usage,
|
|
60
|
+
...(error ? { error } : {}),
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/base/logger.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// JSONL 结构化日志。每行:{ ts, level, event, detail }。
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureDir, nowIso } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
8
|
+
|
|
9
|
+
export class Logger {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string} opts.dir 日志目录
|
|
13
|
+
* @param {string} [opts.level] 最低记录级别
|
|
14
|
+
*/
|
|
15
|
+
constructor({ dir, level = "info" } = {}) {
|
|
16
|
+
this.dir = dir;
|
|
17
|
+
this.threshold = LEVELS[level] ?? LEVELS.info;
|
|
18
|
+
if (dir) ensureDir(dir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_file() {
|
|
22
|
+
const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
23
|
+
return path.join(this.dir, `vanor-${day}.jsonl`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
log(level, event, detail = {}) {
|
|
27
|
+
if ((LEVELS[level] ?? 0) < this.threshold) return;
|
|
28
|
+
const line = JSON.stringify({ ts: nowIso(), level, event, detail });
|
|
29
|
+
if (!this.dir) return;
|
|
30
|
+
try {
|
|
31
|
+
fs.appendFileSync(this._file(), line + "\n");
|
|
32
|
+
} catch {
|
|
33
|
+
// 日志失败不得影响主流程
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
debug(event, detail) {
|
|
38
|
+
this.log("debug", event, detail);
|
|
39
|
+
}
|
|
40
|
+
info(event, detail) {
|
|
41
|
+
this.log("info", event, detail);
|
|
42
|
+
}
|
|
43
|
+
warn(event, detail) {
|
|
44
|
+
this.log("warn", event, detail);
|
|
45
|
+
}
|
|
46
|
+
error(event, detail) {
|
|
47
|
+
this.log("error", event, detail);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 不写盘的空日志器,用于测试。 */
|
|
52
|
+
export class NullLogger extends Logger {
|
|
53
|
+
constructor() {
|
|
54
|
+
super({ dir: null, level: "error" });
|
|
55
|
+
}
|
|
56
|
+
log() {}
|
|
57
|
+
}
|