@raolin2025/claude-code-node 1.1.0 → 2.0.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.md +52 -0
- package/package.json +13 -4
- package/src/channel/index.js +81 -146
- package/src/channel/notify-daemon.js +592 -0
- package/src/core/cli.js +195 -114
- package/src/core/compact.js +171 -0
- package/src/core/config.js +1 -2
- package/src/core/cost-tracker.js +171 -0
- package/src/core/paths.js +12 -0
- package/src/core/query-engine.js +192 -89
- package/src/core/session.js +24 -9
- package/src/mcp/client.js +99 -1
- package/src/mcp/registry.js +1 -2
- package/src/security/bash-guard.js +174 -141
- package/src/security/enhanced-permission.js +72 -34
- package/src/security/path-guard.js +32 -29
- package/src/security/ssrf-guard.js +153 -50
- package/src/tools/glob.js +1 -1
- package/src/types/index.js +2 -1
- package/src/utils/file-ops.js +2 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 费用追踪 (Cost Tracking) — API 调用费用计算和报告
|
|
3
|
+
*
|
|
4
|
+
* 支持主流模型的价格表,自动根据 model 名称匹配价格。
|
|
5
|
+
* 对应原版: src/utils/costTracker.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// 模型价格表(美元 / 1M tokens)
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
const PRICING = {
|
|
13
|
+
// DeepSeek
|
|
14
|
+
'deepseek-chat': { input: 0.27, output: 1.10, cache_read: 0.07 },
|
|
15
|
+
'deepseek-reasoner': { input: 0.55, output: 2.19, cache_read: 0.14 },
|
|
16
|
+
|
|
17
|
+
// OpenAI
|
|
18
|
+
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
19
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
20
|
+
'gpt-4.1': { input: 2.00, output: 8.00 },
|
|
21
|
+
'gpt-4.1-mini': { input: 0.40, output: 1.60 },
|
|
22
|
+
'gpt-4.1-nano': { input: 0.10, output: 0.40 },
|
|
23
|
+
'o3': { input: 2.00, output: 8.00 },
|
|
24
|
+
'o3-mini': { input: 1.10, output: 4.40 },
|
|
25
|
+
'o4-mini': { input: 1.10, output: 4.40 },
|
|
26
|
+
|
|
27
|
+
// 通义千问 (Qwen)
|
|
28
|
+
'qwen-plus': { input: 0.50, output: 2.00 },
|
|
29
|
+
'qwen-turbo': { input: 0.05, output: 0.20 },
|
|
30
|
+
'qwen-max': { input: 2.00, output: 6.00 },
|
|
31
|
+
'qwen-long': { input: 0.07, output: 0.28 },
|
|
32
|
+
|
|
33
|
+
// 智谱 GLM
|
|
34
|
+
'glm-4-flash': { input: 0.10, output: 0.10 },
|
|
35
|
+
'glm-4-plus': { input: 0.50, output: 0.50 },
|
|
36
|
+
'glm-4': { input: 1.00, output: 1.00 },
|
|
37
|
+
'glm-4-long': { input: 0.10, output: 0.10 },
|
|
38
|
+
|
|
39
|
+
// Moonshot Kimi
|
|
40
|
+
'moonshot-v1-8k': { input: 0.50, output: 2.00 },
|
|
41
|
+
'moonshot-v1-32k': { input: 1.00, output: 4.00 },
|
|
42
|
+
'moonshot-v1-128k': { input: 2.00, output: 8.00 },
|
|
43
|
+
|
|
44
|
+
// Ollama (本地免费)
|
|
45
|
+
'ollama': { input: 0, output: 0 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 默认价格(未匹配到的模型)
|
|
49
|
+
const DEFAULT_PRICING = { input: 0.50, output: 2.00 }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 根据模型名查找价格
|
|
53
|
+
*/
|
|
54
|
+
function findPricing(model) {
|
|
55
|
+
if (!model) return DEFAULT_PRICING
|
|
56
|
+
|
|
57
|
+
// 精确匹配
|
|
58
|
+
if (PRICING[model]) return PRICING[model]
|
|
59
|
+
|
|
60
|
+
// 前缀匹配 (e.g. "deepseek-chat-v3" → "deepseek-chat")
|
|
61
|
+
for (const [key, price] of Object.entries(PRICING)) {
|
|
62
|
+
if (model.startsWith(key) || model.includes(key)) return price
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Ollama 系列全部免费
|
|
66
|
+
if (model.includes('ollama') || model.includes('local') || model.includes('localhost')) {
|
|
67
|
+
return PRICING.ollama
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return DEFAULT_PRICING
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 费用追踪器
|
|
75
|
+
*/
|
|
76
|
+
export class CostTracker {
|
|
77
|
+
constructor(options = {}) {
|
|
78
|
+
this.model = options.model || ''
|
|
79
|
+
this.pricing = findPricing(this.model)
|
|
80
|
+
this.totalInputTokens = 0
|
|
81
|
+
this.totalOutputTokens = 0
|
|
82
|
+
this.totalCacheReadTokens = 0
|
|
83
|
+
this.totalApiCalls = 0
|
|
84
|
+
this.history = [] // 每次 API 调用的记录
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 切换模型(更新价格表) */
|
|
88
|
+
setModel(model) {
|
|
89
|
+
this.model = model
|
|
90
|
+
this.pricing = findPricing(model)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 记录一次 API 调用 */
|
|
94
|
+
recordUsage(usage) {
|
|
95
|
+
const inputTokens = usage.input_tokens || usage.prompt_tokens || 0
|
|
96
|
+
const outputTokens = usage.output_tokens || usage.completion_tokens || 0
|
|
97
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
|
98
|
+
|
|
99
|
+
const cost = this.calculateCost(inputTokens, outputTokens, cacheReadTokens)
|
|
100
|
+
|
|
101
|
+
this.totalInputTokens += inputTokens
|
|
102
|
+
this.totalOutputTokens += outputTokens
|
|
103
|
+
this.totalCacheReadTokens += cacheReadTokens
|
|
104
|
+
this.totalApiCalls++
|
|
105
|
+
|
|
106
|
+
this.history.push({
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
inputTokens,
|
|
109
|
+
outputTokens,
|
|
110
|
+
cacheReadTokens,
|
|
111
|
+
cost,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return cost
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 计算单次费用 */
|
|
118
|
+
calculateCost(inputTokens, outputTokens, cacheReadTokens = 0) {
|
|
119
|
+
const cost = {
|
|
120
|
+
input: (inputTokens / 1_000_000) * this.pricing.input,
|
|
121
|
+
output: (outputTokens / 1_000_000) * this.pricing.output,
|
|
122
|
+
cacheRead: (cacheReadTokens / 1_000_000) * (this.pricing.cache_read || 0),
|
|
123
|
+
}
|
|
124
|
+
cost.total = cost.input + cost.output + cost.cacheRead
|
|
125
|
+
return cost
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 获取总费用 */
|
|
129
|
+
getTotalCost() {
|
|
130
|
+
const cost = this.calculateCost(
|
|
131
|
+
this.totalInputTokens,
|
|
132
|
+
this.totalOutputTokens,
|
|
133
|
+
this.totalCacheReadTokens
|
|
134
|
+
)
|
|
135
|
+
return cost
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 格式化费用报告 */
|
|
139
|
+
formatReport() {
|
|
140
|
+
const cost = this.getTotalCost()
|
|
141
|
+
const lines = [
|
|
142
|
+
`💰 Cost Report — ${this.model}`,
|
|
143
|
+
` API Calls: ${this.totalApiCalls}`,
|
|
144
|
+
` Input Tokens: ${this.totalInputTokens.toLocaleString()} → $${cost.input.toFixed(4)}`,
|
|
145
|
+
` Output Tokens: ${this.totalOutputTokens.toLocaleString()} → $${cost.output.toFixed(4)}`,
|
|
146
|
+
]
|
|
147
|
+
if (this.totalCacheReadTokens > 0) {
|
|
148
|
+
lines.push(` Cache Read: ${this.totalCacheReadTokens.toLocaleString()} → $${cost.cacheRead.toFixed(4)}`)
|
|
149
|
+
}
|
|
150
|
+
lines.push(` ───────────────────────────────`)
|
|
151
|
+
lines.push(` Total: $${cost.total.toFixed(4)}`)
|
|
152
|
+
return lines.join('\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 简短格式(单行) */
|
|
156
|
+
formatShort() {
|
|
157
|
+
const cost = this.getTotalCost()
|
|
158
|
+
return `$${cost.total.toFixed(4)} (${this.totalApiCalls} calls, ${this.totalInputTokens.toLocaleString()}+${this.totalOutputTokens.toLocaleString()} tok)`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 重置 */
|
|
162
|
+
reset() {
|
|
163
|
+
this.totalInputTokens = 0
|
|
164
|
+
this.totalOutputTokens = 0
|
|
165
|
+
this.totalCacheReadTokens = 0
|
|
166
|
+
this.totalApiCalls = 0
|
|
167
|
+
this.history = []
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { PRICING, findPricing }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享路径常量 — cc-node 和 cc-notify 共用
|
|
3
|
+
*/
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
|
|
7
|
+
export const SOCK_DIR = join(homedir(), '.cc-node')
|
|
8
|
+
export const SOCK_PATH = join(SOCK_DIR, 'repl.sock')
|
|
9
|
+
export const CC_NODE_PID = join(SOCK_DIR, 'cc-node.pid')
|
|
10
|
+
export const CC_NOTIFY_PID = join(SOCK_DIR, 'cc-notify.pid')
|
|
11
|
+
export const CC_NOTIFY_LOG = join(SOCK_DIR, 'cc-notify.log')
|
|
12
|
+
export const DEFAULT_HTTP_PORT = 3456
|
package/src/core/query-engine.js
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
* 适用于: OpenAI / DeepSeek / Qwen / GLM / Kimi / Ollama / vLLM / LM Studio / 任何兼容接口
|
|
13
13
|
*/
|
|
14
14
|
import crypto from 'crypto'
|
|
15
|
-
import {
|
|
15
|
+
import { UserMessage, AssistantMessage, ToolCall, ToolResult, SessionState } from '../types/index.js'
|
|
16
|
+
import { parseStream, parseNonStreamResponse } from './streaming.js'
|
|
17
|
+
import { autoCompact } from './compact.js'
|
|
18
|
+
import { CostTracker } from './cost-tracker.js'
|
|
16
19
|
import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
|
|
20
|
+
import { checkHostSafety } from '../security/ssrf-guard.js'
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* 配置选项
|
|
@@ -35,6 +39,9 @@ export class QueryEngineConfig {
|
|
|
35
39
|
this.apiKey = options.apiKey || process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || process.env.QWEN_API_KEY || process.env.GLM_API_KEY || process.env.KIMI_API_KEY || ''
|
|
36
40
|
// API Base — DeepSeek 为默认
|
|
37
41
|
this.apiBase = options.apiBase || process.env.LLM_API_BASE || 'https://api.deepseek.com/v1'
|
|
42
|
+
this.noStream = options.noStream || false
|
|
43
|
+
this.costTracker = options.costTracker || null
|
|
44
|
+
this.tokenBudget = options.tokenBudget || null
|
|
38
45
|
this.initialMessages = options.initialMessages || []
|
|
39
46
|
}
|
|
40
47
|
}
|
|
@@ -51,6 +58,8 @@ export class QueryEngine {
|
|
|
51
58
|
projectDir: this.config.cwd,
|
|
52
59
|
})
|
|
53
60
|
this.abortController = null
|
|
61
|
+
this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
|
|
62
|
+
this.tokenBudget = this.config.tokenBudget || null
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
/**
|
|
@@ -66,6 +75,15 @@ export class QueryEngine {
|
|
|
66
75
|
const userMsg = new UserMessage(userInput)
|
|
67
76
|
this.state.messages.push(userMsg)
|
|
68
77
|
|
|
78
|
+
// M3: 自动上下文压缩
|
|
79
|
+
if (this.tokenBudget) {
|
|
80
|
+
const { compacted, messages } = autoCompact(this.state.messages, this.tokenBudget)
|
|
81
|
+
if (compacted) {
|
|
82
|
+
this.state.messages = messages
|
|
83
|
+
if (this.config.verbose) console.error('[compact] Context compressed to fit token budget')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
try {
|
|
70
88
|
const result = await this._runToolLoop(userMsg)
|
|
71
89
|
return result
|
|
@@ -81,12 +99,11 @@ export class QueryEngine {
|
|
|
81
99
|
* 最多跑 maxTurns 次
|
|
82
100
|
*/
|
|
83
101
|
async _runToolLoop(userMessage) {
|
|
84
|
-
let currentMessages = [...this.state.messages]
|
|
85
102
|
let finalResponse = ''
|
|
86
103
|
|
|
87
104
|
for (let turn = 0; turn < this.config.maxTurns; turn++) {
|
|
88
|
-
const requestMessages = this._buildRequest(
|
|
89
|
-
const response = await this._callLLM(requestMessages,
|
|
105
|
+
const requestMessages = this._buildRequest(this.state.messages)
|
|
106
|
+
const response = await this._callLLM(requestMessages, this.state.messages)
|
|
90
107
|
|
|
91
108
|
if (this.abortController.signal.aborted) {
|
|
92
109
|
throw new Error('操作已取消')
|
|
@@ -95,40 +112,22 @@ export class QueryEngine {
|
|
|
95
112
|
// 没有工具调用 → 最终回复
|
|
96
113
|
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
97
114
|
finalResponse = response.content
|
|
98
|
-
|
|
99
|
-
this.state.messages.push(assistantMsg)
|
|
115
|
+
this.state.messages.push(new AssistantMessage(response.content))
|
|
100
116
|
break
|
|
101
117
|
}
|
|
102
118
|
|
|
103
|
-
// 有工具调用 →
|
|
104
|
-
|
|
105
|
-
this.state.messages.push(assistantMsg)
|
|
119
|
+
// 有工具调用 → 记录 assistant 消息(含 tool_calls)
|
|
120
|
+
this.state.messages.push(new AssistantMessage(response.content, response.toolCalls))
|
|
106
121
|
|
|
107
122
|
// 执行工具
|
|
108
123
|
const toolResults = await this._executeToolCalls(response.toolCalls)
|
|
109
124
|
|
|
110
|
-
//
|
|
125
|
+
// 工具结果加入 state.messages(OpenAI 兼容格式)
|
|
111
126
|
for (const result of toolResults) {
|
|
112
|
-
|
|
113
|
-
currentMessages.push({
|
|
114
|
-
role: 'assistant',
|
|
115
|
-
content: null,
|
|
116
|
-
tool_calls: [{
|
|
117
|
-
id: result.toolCallId,
|
|
118
|
-
type: 'function',
|
|
119
|
-
function: {
|
|
120
|
-
name: result.toolName || '',
|
|
121
|
-
arguments: '{}',
|
|
122
|
-
},
|
|
123
|
-
}],
|
|
124
|
-
})
|
|
125
|
-
// 再加 tool 结果消息
|
|
126
|
-
currentMessages.push({
|
|
127
|
+
this.state.messages.push({
|
|
127
128
|
role: 'tool',
|
|
128
129
|
tool_call_id: result.toolCallId,
|
|
129
|
-
content: result.isError
|
|
130
|
-
? `[ERROR] ${result.content}`
|
|
131
|
-
: result.content,
|
|
130
|
+
content: result.isError ? `[ERROR] ${result.content}` : result.content,
|
|
132
131
|
})
|
|
133
132
|
this.state.toolResults.set(result.toolCallId, result)
|
|
134
133
|
}
|
|
@@ -150,7 +149,7 @@ export class QueryEngine {
|
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
/**
|
|
153
|
-
* 构建 LLM 请求消息列表
|
|
152
|
+
* 构建 LLM 请求消息列表 — 统一 OpenAI 兼容格式
|
|
154
153
|
*/
|
|
155
154
|
_buildRequest(messages) {
|
|
156
155
|
const request = []
|
|
@@ -160,37 +159,53 @@ export class QueryEngine {
|
|
|
160
159
|
request.push({ role: 'system', content: this.config.systemPrompt })
|
|
161
160
|
}
|
|
162
161
|
|
|
163
|
-
// 历史消息
|
|
162
|
+
// 历史消息 — 转换为 OpenAI 兼容格式
|
|
164
163
|
for (const msg of messages) {
|
|
165
164
|
if (msg.role === 'system') {
|
|
166
165
|
request.push({ role: 'system', content: msg.content })
|
|
167
166
|
} else if (msg.role === 'user') {
|
|
168
167
|
request.push({ role: 'user', content: this._formatContent(msg.content) })
|
|
169
168
|
} else if (msg.role === 'assistant') {
|
|
170
|
-
|
|
171
|
-
if (msg.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
content
|
|
175
|
-
|
|
169
|
+
// 带 tool_calls 的 assistant 消息:OpenAI 格式
|
|
170
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
171
|
+
request.push({
|
|
172
|
+
role: 'assistant',
|
|
173
|
+
content: msg.content || null,
|
|
174
|
+
tool_calls: msg.toolCalls.map(tc => ({
|
|
175
|
+
id: tc.id,
|
|
176
|
+
type: 'function',
|
|
177
|
+
function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input) },
|
|
178
|
+
})),
|
|
179
|
+
})
|
|
180
|
+
} else {
|
|
181
|
+
// 纯文本 assistant 消息
|
|
182
|
+
request.push({ role: 'assistant', content: this._formatContent(msg.content) })
|
|
176
183
|
}
|
|
177
|
-
|
|
184
|
+
} else if (msg.role === 'tool') {
|
|
185
|
+
// tool 结果消息 — 直接透传
|
|
186
|
+
request.push({
|
|
187
|
+
role: 'tool',
|
|
188
|
+
tool_call_id: msg.tool_call_id,
|
|
189
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
190
|
+
})
|
|
178
191
|
}
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
return request
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
|
|
184
198
|
/**
|
|
185
199
|
* 执行工具调用
|
|
186
200
|
*/
|
|
187
201
|
async _executeToolCalls(toolCalls) {
|
|
188
202
|
const results = []
|
|
189
203
|
for (const tc of toolCalls) {
|
|
190
|
-
//
|
|
204
|
+
// 安全检查
|
|
191
205
|
const permResult = await this.permissionChecker.check(tc.name, tc.input)
|
|
192
206
|
if (!permResult.allowed) {
|
|
193
|
-
results.push(new ToolResult(tc.id, `工具调用被安全策略拒绝: ${tc.name} — ${permResult.reason ||
|
|
207
|
+
results.push(new ToolResult(tc.id, `工具调用被安全策略拒绝: ${tc.name} — ${permResult.reason || ""}`, true))
|
|
208
|
+
results[results.length - 1].toolName = tc.name
|
|
194
209
|
continue
|
|
195
210
|
}
|
|
196
211
|
|
|
@@ -198,6 +213,7 @@ export class QueryEngine {
|
|
|
198
213
|
const tool = this.config.tools.find(t => t.name === tc.name)
|
|
199
214
|
if (!tool) {
|
|
200
215
|
results.push(new ToolResult(tc.id, `未找到工具: ${tc.name}`, true))
|
|
216
|
+
results[results.length - 1].toolName = tc.name
|
|
201
217
|
continue
|
|
202
218
|
}
|
|
203
219
|
|
|
@@ -206,7 +222,6 @@ export class QueryEngine {
|
|
|
206
222
|
const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this })
|
|
207
223
|
tc.status = 'done'
|
|
208
224
|
results.push(new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false))
|
|
209
|
-
// 记录工具名用于消息构建
|
|
210
225
|
results[results.length - 1].toolName = tc.name
|
|
211
226
|
} catch (err) {
|
|
212
227
|
tc.status = 'error'
|
|
@@ -217,19 +232,6 @@ export class QueryEngine {
|
|
|
217
232
|
return results
|
|
218
233
|
}
|
|
219
234
|
|
|
220
|
-
/**
|
|
221
|
-
* 调用 LLM API — OpenAI 兼容协议(全行业通用)
|
|
222
|
-
*
|
|
223
|
-
* 支持的提供商(举例):
|
|
224
|
-
* - DeepSeek: https://api.deepseek.com/v1
|
|
225
|
-
* - 通义千问: https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
226
|
-
* - 智谱 GLM: https://open.bigmodel.cn/api/paas/v4
|
|
227
|
-
* - Moonshot Kimi: https://api.moonshot.cn/v1
|
|
228
|
-
* - Ollama 本地: http://localhost:11434/v1
|
|
229
|
-
* - vLLM: http://localhost:8000/v1
|
|
230
|
-
* - LM Studio: http://localhost:1234/v1
|
|
231
|
-
* - OpenAI: https://api.openai.com/v1
|
|
232
|
-
*/
|
|
233
235
|
async _callLLM(messages, contextMessages) {
|
|
234
236
|
const apiKey = this.config.apiKey
|
|
235
237
|
const apiBase = this.config.apiBase
|
|
@@ -237,69 +239,162 @@ export class QueryEngine {
|
|
|
237
239
|
if (!apiKey) {
|
|
238
240
|
throw new Error(
|
|
239
241
|
`未设置 API Key。请设置以下环境变量之一:\n` +
|
|
240
|
-
` LLM_API_KEY=xxx
|
|
241
|
-
` DEEPSEEK_API_KEY=xxx
|
|
242
|
-
` OPENAI_API_KEY=xxx
|
|
243
|
-
` QWEN_API_KEY=xxx
|
|
244
|
-
` GLM_API_KEY=xxx
|
|
245
|
-
` KIMI_API_KEY=xxx
|
|
242
|
+
` LLM_API_KEY=xxx (通用,推荐)\n` +
|
|
243
|
+
` DEEPSEEK_API_KEY=xxx (DeepSeek)\n` +
|
|
244
|
+
` OPENAI_API_KEY=xxx (OpenAI)\n` +
|
|
245
|
+
` QWEN_API_KEY=xxx (通义千问)\n` +
|
|
246
|
+
` GLM_API_KEY=xxx (智谱 GLM)\n` +
|
|
247
|
+
` KIMI_API_KEY=xxx (Moonshot Kimi)\n` +
|
|
246
248
|
`或通过 --api-key 参数传入`
|
|
247
249
|
)
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
if (!apiBase) {
|
|
251
253
|
throw new Error(
|
|
252
|
-
`未设置 API Base URL。默认使用 https://api.deepseek.com/v1
|
|
253
|
-
|
|
254
|
-
`可通过 LLM_API_BASE 或 --api-base 参数切换其他提供商,例如:\n` +
|
|
255
|
-
` --api-base https://dashscope.aliyuncs.com/compatible-mode/v1 (通义千问)\n` +
|
|
256
|
-
` --api-base https://open.bigmodel.cn/api/paas/v4 (智谱 GLM)\n` +
|
|
257
|
-
` --api-base https://api.moonshot.cn/v1 (Moonshot Kimi)\n` +
|
|
258
|
-
` --api-base http://localhost:11434/v1 (Ollama 本地)\n` +
|
|
259
|
-
` --api-base http://localhost:8000/v1 (vLLM 本地)\n` +
|
|
260
|
-
` --api-base https://api.openai.com/v1 (OpenAI)`
|
|
254
|
+
`未设置 API Base URL。默认使用 https://api.deepseek.com/v1 ` +
|
|
255
|
+
`可通过 LLM_API_BASE 或 --api-base 参数切换其他提供商`
|
|
261
256
|
)
|
|
262
257
|
}
|
|
263
258
|
|
|
264
|
-
// 构建工具定义
|
|
259
|
+
// 构建工具定义
|
|
265
260
|
const tools = this.config.tools.map(t => ({
|
|
266
261
|
type: 'function',
|
|
267
|
-
function: {
|
|
268
|
-
name: t.name,
|
|
269
|
-
description: t.description,
|
|
270
|
-
parameters: t.parameters,
|
|
271
|
-
},
|
|
262
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
272
263
|
}))
|
|
273
264
|
|
|
265
|
+
const useStream = !this.config.noStream
|
|
274
266
|
const body = {
|
|
275
267
|
model: this.config.model,
|
|
276
268
|
messages,
|
|
277
269
|
max_tokens: 4096,
|
|
278
270
|
...(tools.length && { tools }),
|
|
271
|
+
...(useStream && { stream: true }),
|
|
279
272
|
}
|
|
280
273
|
|
|
281
274
|
const url = apiBase.replace(/\/+$/, '') + '/chat/completions'
|
|
282
275
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
throw new Error(`API 错误 ${response.status}: ${errText}`)
|
|
276
|
+
// H3 修复:SSRF 防护 — 在 fetch 之前检查 API 主机名
|
|
277
|
+
try {
|
|
278
|
+
const parsedUrl = new URL(url)
|
|
279
|
+
const hostResult = await checkHostSafety(parsedUrl.hostname)
|
|
280
|
+
if (!hostResult.allowed) {
|
|
281
|
+
throw new Error(`SSRF blocked: ${hostResult.reason}`)
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err.message.startsWith('SSRF blocked:')) {
|
|
285
|
+
throw err
|
|
286
|
+
}
|
|
287
|
+
// URL 解析错误忽略,让 fetch 自己处理
|
|
296
288
|
}
|
|
297
289
|
|
|
298
|
-
|
|
299
|
-
|
|
290
|
+
// 带重试的 fetch
|
|
291
|
+
const maxRetries = 3
|
|
292
|
+
let lastError = null
|
|
293
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
294
|
+
try {
|
|
295
|
+
if (this.abortController?.signal?.aborted) {
|
|
296
|
+
throw new Error('请求已取消')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const response = await fetch(url, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: {
|
|
302
|
+
'Content-Type': 'application/json',
|
|
303
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
304
|
+
},
|
|
305
|
+
body: JSON.stringify(body),
|
|
306
|
+
signal: this.abortController?.signal,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
const errText = await response.text()
|
|
311
|
+
// 429/503 可重试
|
|
312
|
+
if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
|
|
313
|
+
const waitMs = response.status === 429 ? 2000 * attempt : 1000
|
|
314
|
+
if (this.config.verbose) {
|
|
315
|
+
console.error(`[retry] API ${response.status}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
316
|
+
}
|
|
317
|
+
await new Promise(r => setTimeout(r, waitMs))
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`API 错误 ${response.status}: ${errText}`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 流式或非流式处理
|
|
324
|
+
if (useStream && response.body) {
|
|
325
|
+
return await this._handleStreamResponse(response)
|
|
326
|
+
} else {
|
|
327
|
+
const data = await response.json()
|
|
328
|
+
const result = parseNonStreamResponse(data)
|
|
329
|
+
// M4: 记录非流式响应费用
|
|
330
|
+
if (result.usage && this.costTracker) {
|
|
331
|
+
this.costTracker.recordUsage(result.usage)
|
|
332
|
+
}
|
|
333
|
+
if (this.tokenBudget && result.usage) {
|
|
334
|
+
this.tokenBudget.recordUsage(result.usage)
|
|
335
|
+
}
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
lastError = err
|
|
340
|
+
// 网络错误重试
|
|
341
|
+
if (err.name !== 'AbortError' && attempt < maxRetries && !err.message.startsWith('API 错误')) {
|
|
342
|
+
const waitMs = 1000 * attempt
|
|
343
|
+
if (this.config.verbose) {
|
|
344
|
+
console.error(`[retry] Network error: ${err.message}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
345
|
+
}
|
|
346
|
+
await new Promise(r => setTimeout(r, waitMs))
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
throw err
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
throw lastError
|
|
300
353
|
}
|
|
301
354
|
|
|
302
355
|
/**
|
|
356
|
+
* 处理流式响应 — 逐 token 输出
|
|
357
|
+
*/
|
|
358
|
+
async _handleStreamResponse(response) {
|
|
359
|
+
const result = { content: '', toolCalls: [], usage: {} }
|
|
360
|
+
let currentText = ''
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
for await (const event of parseStream(response)) {
|
|
364
|
+
if (event.type === 'text') {
|
|
365
|
+
// 实时输出到终端
|
|
366
|
+
process.stdout.write(event.text)
|
|
367
|
+
currentText += event.text
|
|
368
|
+
} else if (event.type === 'tool_use') {
|
|
369
|
+
// 收集工具调用
|
|
370
|
+
result.toolCalls.push(new ToolCall(
|
|
371
|
+
event.toolCall.id,
|
|
372
|
+
event.toolCall.name,
|
|
373
|
+
event.toolCall.input
|
|
374
|
+
))
|
|
375
|
+
} else if (event.type === 'done') {
|
|
376
|
+
result.content = event.result.content || currentText
|
|
377
|
+
result.toolCalls = event.result.toolCalls?.map(tc =>
|
|
378
|
+
new ToolCall(tc.id, tc.name, tc.input)
|
|
379
|
+
) || result.toolCalls
|
|
380
|
+
result.usage = event.result.usage || {}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// 流中断 — 返回已收到的内容
|
|
385
|
+
result.content = currentText || ''
|
|
386
|
+
if (this.config.verbose) {
|
|
387
|
+
console.error(`[stream] interrupted: ${err.message}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 流式输出后换行
|
|
392
|
+
if (currentText) process.stdout.write('\n')
|
|
393
|
+
|
|
394
|
+
return result
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
303
398
|
* 解析 OpenAI 兼容响应
|
|
304
399
|
*/
|
|
305
400
|
_parseResponse(data) {
|
|
@@ -322,6 +417,14 @@ export class QueryEngine {
|
|
|
322
417
|
result.toolCalls.push(new ToolCall(tc.id, tc.function.name, input))
|
|
323
418
|
}
|
|
324
419
|
|
|
420
|
+
// M4: 记录 API 调用费用
|
|
421
|
+
if (result.usage && this.costTracker) {
|
|
422
|
+
this.costTracker.recordUsage(result.usage)
|
|
423
|
+
}
|
|
424
|
+
if (this.tokenBudget && result.usage) {
|
|
425
|
+
this.tokenBudget.recordUsage(result.usage)
|
|
426
|
+
}
|
|
427
|
+
|
|
325
428
|
return result
|
|
326
429
|
}
|
|
327
430
|
|
package/src/core/session.js
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
* 会话管理
|
|
3
3
|
* 对应原版: src/utils/sessionState.ts + src/utils/sessionStorage.ts
|
|
4
4
|
*/
|
|
5
|
-
import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises'
|
|
5
|
+
import { readFile, writeFile, mkdir, readdir, rm, chmod } from 'fs/promises'
|
|
6
6
|
import { resolve, join } from 'path'
|
|
7
|
-
import { existsSync } from 'fs'
|
|
8
|
-
import { SessionState } from '../types/index.js'
|
|
9
7
|
|
|
10
8
|
const DEFAULT_SESSIONS_DIR = '.claude-code/sessions'
|
|
11
9
|
|
|
@@ -15,9 +13,9 @@ export class SessionManager {
|
|
|
15
13
|
this.currentSession = null
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
/**
|
|
16
|
+
/** 确保会话目录存在(v1.1: 目录权限 0700) */
|
|
19
17
|
async ensureDir() {
|
|
20
|
-
await mkdir(this.sessionsDir, { recursive: true })
|
|
18
|
+
await mkdir(this.sessionsDir, { recursive: true, mode: 0o700 })
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/** 创建新会话 */
|
|
@@ -37,12 +35,12 @@ export class SessionManager {
|
|
|
37
35
|
return session
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
/**
|
|
38
|
+
/** 保存会话(v1.1: 文件权限 0600,防止其他用户读取敏感对话内容) */
|
|
41
39
|
async save(session) {
|
|
42
40
|
await this.ensureDir()
|
|
43
41
|
session.updated = new Date().toISOString()
|
|
44
42
|
const filePath = join(this.sessionsDir, `${session.id}.json`)
|
|
45
|
-
await writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8')
|
|
43
|
+
await writeFile(filePath, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
46
44
|
return session
|
|
47
45
|
}
|
|
48
46
|
|
|
@@ -109,11 +107,28 @@ export class SessionManager {
|
|
|
109
107
|
/** 追加消息到当前会话 */
|
|
110
108
|
async appendMessage(message) {
|
|
111
109
|
if (!this.currentSession) await this.getOrCreate()
|
|
112
|
-
|
|
110
|
+
|
|
111
|
+
const entry = {
|
|
113
112
|
role: message.role,
|
|
114
113
|
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
|
|
115
114
|
timestamp: new Date().toISOString(),
|
|
116
|
-
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 保存 tool_calls 信息(assistant 消息可能包含工具调用)
|
|
118
|
+
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
119
|
+
entry.toolCalls = message.toolCalls.map(tc => ({
|
|
120
|
+
id: tc.id,
|
|
121
|
+
name: tc.name,
|
|
122
|
+
input: tc.input,
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 保存 tool_call_id(tool 结果消息)
|
|
127
|
+
if (message.tool_call_id) {
|
|
128
|
+
entry.tool_call_id = message.tool_call_id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.currentSession.messages.push(entry)
|
|
117
132
|
await this.save(this.currentSession)
|
|
118
133
|
return this.currentSession
|
|
119
134
|
}
|