@raolin2025/claude-code-node 2.5.2 → 2.6.1
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 +16 -2
- package/package.json +1 -1
- package/src/__tests__/llm-server.test.js +62 -0
- package/src/core/cli.js +109 -58
- package/src/core/query-engine.js +5 -2
- package/src/utils/index.js +1 -0
- package/src/utils/llm-server.js +106 -0
package/README.md
CHANGED
|
@@ -206,10 +206,24 @@ cc-node --model kimi-k2-0711 --api-base https://api.moonshot.cn/v1
|
|
|
206
206
|
# OpenAI
|
|
207
207
|
cc-node --model gpt-4o --api-base https://api.openai.com/v1
|
|
208
208
|
|
|
209
|
-
# Ollama
|
|
210
|
-
|
|
209
|
+
# Ollama / 自建本地服务(Ollama / llama.cpp / vLLM 等)
|
|
210
|
+
# 指向 localhost 或内网地址时【无需 apiKey】,直接可用
|
|
211
|
+
cc-node --api-base http://localhost:11434/v1 --model qwen2.5
|
|
212
|
+
|
|
213
|
+
# 内网自建服务(局域网 IP,同样无需 apiKey)
|
|
214
|
+
# 将 <内网IP> 替换为你的服务器地址,如 192.168.x.x
|
|
215
|
+
cc-node --api-base http://<内网IP>:11434/v1 --model qwen3.6:27b
|
|
216
|
+
|
|
217
|
+
# 未指定模型 → 自动拉取服务端 /models 列表供交互选择
|
|
218
|
+
cc-node --api-base http://localhost:11434/v1
|
|
211
219
|
```
|
|
212
220
|
|
|
221
|
+
> 💡 **自建本地服务支持**:cc-node 会自动识别 apiBase 指向本地/内网
|
|
222
|
+
> (`localhost`、回环、`192.168.x` / `10.x` / `172.16-31.x` 私有段、`.local` 域名)
|
|
223
|
+
> 的自建 LLM 服务(Ollama、llama.cpp、vLLM 等 OpenAI 兼容端点),
|
|
224
|
+
> 此类服务**无需 apiKey 也能正常调用**,且不会强制附加 `Bearer undefined` 头。
|
|
225
|
+
> 云端厂商(DeepSeek/OpenAI/Kimi 等)仍需 apiKey。
|
|
226
|
+
|
|
213
227
|
[](https://www.npmjs.com/package/@raolin2025/claude-code-node) [](https://github.com/bg1avd/claude-code-node) [](https://opensource.org/licenses/MIT)
|
|
214
228
|
---
|
|
215
229
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llm-server 单元测试
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 isLocalLlmServer / isLocalHostname / buildAuthHeaders
|
|
5
|
+
* 用于验证自建本地 LLM 服务(Ollama / llama.cpp / vLLM)识别逻辑
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test, describe } from 'node:test'
|
|
9
|
+
import assert from 'node:assert'
|
|
10
|
+
|
|
11
|
+
import { isLocalLlmServer, isLocalHostname, buildAuthHeaders } from '../utils/llm-server.js'
|
|
12
|
+
|
|
13
|
+
describe('isLocalHostname', () => {
|
|
14
|
+
const localCases = ['localhost', '127.0.0.1', '192.168.1.50', '10.0.0.5', '172.20.1.1',
|
|
15
|
+
'172.16.1.1', '172.31.255.255', '169.254.1.1', 'myhost.local', 'server.lan', '::1', 'fc00::1']
|
|
16
|
+
localCases.forEach(h => {
|
|
17
|
+
test(`应识别为本地: ${h}`, () => assert.strictEqual(isLocalHostname(h), true))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// 172.5 不在私有段(172.16-31)
|
|
21
|
+
const nonLocalCases = ['172.5.1.1', '8.8.8.8', '114.114.114.114', 'api.deepseek.com', 'example.com']
|
|
22
|
+
nonLocalCases.forEach(h => {
|
|
23
|
+
test(`不应识别为本地: ${h}`, () => assert.strictEqual(isLocalHostname(h), false))
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('isLocalLlmServer', () => {
|
|
28
|
+
test('Ollama 本机', () => assert.strictEqual(isLocalLlmServer('http://localhost:11434/v1'), true))
|
|
29
|
+
test('Ollama 回环', () => assert.strictEqual(isLocalLlmServer('http://127.0.0.1:11434/v1'), true))
|
|
30
|
+
test('Ollama 内网', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50:11434/v1'), true))
|
|
31
|
+
test('vLLM 内网 10.x', () => assert.strictEqual(isLocalLlmServer('http://10.0.0.5:8000/v1'), true))
|
|
32
|
+
test('llama.cpp 内网 172.x', () => assert.strictEqual(isLocalLlmServer('http://172.20.1.1:8000/v1'), true))
|
|
33
|
+
test('无端口内网', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50/v1'), true))
|
|
34
|
+
test('.local 域名', () => assert.strictEqual(isLocalLlmServer('http://myhost.local:11434/v1'), true))
|
|
35
|
+
test('无 /v1 前缀也可', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50:11434'), true))
|
|
36
|
+
|
|
37
|
+
test('DeepSeek 云端', () => assert.strictEqual(isLocalLlmServer('https://api.deepseek.com/v1'), false))
|
|
38
|
+
test('OpenAI 云端', () => assert.strictEqual(isLocalLlmServer('https://api.openai.com/v1'), false))
|
|
39
|
+
test('通义云端', () => assert.strictEqual(isLocalLlmServer('https://dashscope.aliyuncs.com/v1'), false))
|
|
40
|
+
test('公网 IP', () => assert.strictEqual(isLocalLlmServer('http://114.114.114.114:11434/v1'), false))
|
|
41
|
+
test('空值', () => assert.strictEqual(isLocalLlmServer(''), false))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('buildAuthHeaders', () => {
|
|
45
|
+
test('本地服务 + 有 key → 带 Bearer', () => {
|
|
46
|
+
assert.deepStrictEqual(buildAuthHeaders('http://192.168.1.50:11434/v1', 'secret'),
|
|
47
|
+
{ 'Authorization': 'Bearer secret' })
|
|
48
|
+
})
|
|
49
|
+
test('本地服务 + 无 key → 空对象(不带Authorization)', () => {
|
|
50
|
+
assert.deepStrictEqual(buildAuthHeaders('http://192.168.1.50:11434/v1', ''), {})
|
|
51
|
+
})
|
|
52
|
+
test('云端 + 有 key → 带 Bearer', () => {
|
|
53
|
+
assert.deepStrictEqual(buildAuthHeaders('https://api.deepseek.com/v1', 'sk-x'),
|
|
54
|
+
{ 'Authorization': 'Bearer sk-x' })
|
|
55
|
+
})
|
|
56
|
+
test('云端 + 无 key → undefined', () => {
|
|
57
|
+
assert.strictEqual(buildAuthHeaders('https://api.deepseek.com/v1', ''), undefined)
|
|
58
|
+
})
|
|
59
|
+
test('空 apiBase + 无 key → undefined', () => {
|
|
60
|
+
assert.strictEqual(buildAuthHeaders('', ''), undefined)
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/core/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
|
|
6
6
|
*/
|
|
7
|
-
import
|
|
7
|
+
import * as readline from 'readline'
|
|
8
8
|
import { createServer as createNetServer } from 'net'
|
|
9
9
|
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
|
|
10
10
|
import { join } from 'path'
|
|
@@ -16,6 +16,7 @@ import { TokenBudget } from './token-budget.js'
|
|
|
16
16
|
import { ChannelManager } from '../channel/index.js'
|
|
17
17
|
import { CostTracker } from './cost-tracker.js'
|
|
18
18
|
import { autoCompact } from './compact.js'
|
|
19
|
+
import { isLocalLlmServer } from '../utils/index.js'
|
|
19
20
|
import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
|
|
20
21
|
|
|
21
22
|
// ============================================================
|
|
@@ -408,15 +409,23 @@ export async function main() {
|
|
|
408
409
|
await config.load(process.cwd())
|
|
409
410
|
|
|
410
411
|
const apiBase = cliArgs.apiBase || config.get('apiBase') || process.env.LLM_API_BASE || ''
|
|
412
|
+
// apiBase 指向自建本地服务(Ollama / llama.cpp / vLLM 等)时允许缺省 apiKey
|
|
413
|
+
const localApiBase = isLocalLlmServer(apiBase)
|
|
411
414
|
let model = cliArgs.model || config.get('model') || ''
|
|
412
415
|
// 未指定模型 → 自动从 API 拉取模型列表让用户选择
|
|
416
|
+
// 本地自建服务即使无 key 也尝试拉取(这些服务通常无需认证)
|
|
413
417
|
if (!model && apiBase) {
|
|
414
418
|
const apiKeyForModels = cliArgs.apiKey || config.get('apiKey') || process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || ''
|
|
415
|
-
if (apiKeyForModels) {
|
|
419
|
+
if (apiKeyForModels || localApiBase) {
|
|
416
420
|
try {
|
|
417
421
|
const modelsUrl = apiBase.replace(/\/+$/, '') + '/models'
|
|
418
422
|
console.log('⚠️ 未指定模型,正在从 API 获取可用模型列表...')
|
|
419
|
-
const res = await fetch(modelsUrl, {
|
|
423
|
+
const res = await fetch(modelsUrl, {
|
|
424
|
+
headers: {
|
|
425
|
+
'Content-Type': 'application/json',
|
|
426
|
+
...(apiKeyForModels ? { 'Authorization': `Bearer ${apiKeyForModels}` } : {}),
|
|
427
|
+
},
|
|
428
|
+
})
|
|
420
429
|
if (res.ok) {
|
|
421
430
|
const data = await res.json()
|
|
422
431
|
const models = data.data || []
|
|
@@ -428,7 +437,7 @@ export async function main() {
|
|
|
428
437
|
})
|
|
429
438
|
console.log('输入编号选择,或直接输入模型名(回车跳过用 deepseek-chat):')
|
|
430
439
|
// 用 readline 等待输入(此时 REPL 还没启动,需要临时创建)
|
|
431
|
-
const tmpRl = createInterface({ input: process.stdin, output: process.stdout })
|
|
440
|
+
const tmpRl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
432
441
|
const answer = await new Promise(resolve => tmpRl.question('> ', resolve))
|
|
433
442
|
tmpRl.close()
|
|
434
443
|
const num = parseInt(answer, 10)
|
|
@@ -541,52 +550,29 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
541
550
|
// REPL 模式 — 启动 Unix socket 让 cc-notify 能发现
|
|
542
551
|
startSocketServer(engine, session, sessionManager, channelManager, verbose)
|
|
543
552
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
resolve(true)
|
|
559
|
-
} else {
|
|
560
|
-
resolve(a === 'y')
|
|
561
|
-
}
|
|
562
|
-
})
|
|
563
|
-
})
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
engine.config.readline = rl
|
|
567
|
-
|
|
568
|
-
console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
|
|
569
|
-
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
570
|
-
console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
|
|
571
|
-
if (channelManager.list().length > 0) {
|
|
572
|
-
const chList = channelManager.list().join(', ')
|
|
573
|
-
const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
|
|
574
|
-
console.log(`Channels: ${chList}${def}`)
|
|
575
|
-
}
|
|
576
|
-
console.log()
|
|
577
|
-
rl.prompt()
|
|
578
|
-
|
|
579
|
-
// REPL 消息处理包装(留作扩展点)
|
|
580
|
-
async function processInput(input) {
|
|
581
|
-
return engine.processMessage(input)
|
|
553
|
+
// ============================================================
|
|
554
|
+
// REPL 输入处理
|
|
555
|
+
//
|
|
556
|
+
// 使用标准 readline 的 line 事件(terminal: true)实现输入读取:
|
|
557
|
+
// - 字符回显、退格删除、行回绕、方向键历史由 readline 原生处理,
|
|
558
|
+
// 行为与 v2.5 一致,避免手动 keypress 回显导致的显示错乱。
|
|
559
|
+
// - Enter 提交;Ctrl+C 退出。
|
|
560
|
+
// - 非 TTY(管道/重定向)模式复用同一 readline 接口。
|
|
561
|
+
// ============================================================
|
|
562
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
|
|
563
|
+
|
|
564
|
+
// 显示提示符
|
|
565
|
+
function showPrompt() {
|
|
566
|
+
rl.prompt()
|
|
582
567
|
}
|
|
583
568
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
569
|
+
// 处理输入行
|
|
570
|
+
async function processInputLine(input) {
|
|
571
|
+
const trimmed = input.trim()
|
|
572
|
+
if (!trimmed) { showPrompt(); return }
|
|
587
573
|
|
|
588
|
-
if (
|
|
589
|
-
const [cmd, ...rest] =
|
|
574
|
+
if (trimmed.startsWith('/')) {
|
|
575
|
+
const [cmd, ...rest] = trimmed.slice(1).split(' ')
|
|
590
576
|
switch (cmd) {
|
|
591
577
|
case 'help':
|
|
592
578
|
if (rest[0]) {
|
|
@@ -604,11 +590,18 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
604
590
|
case 'models': {
|
|
605
591
|
const apiBase = engine.config.apiBase
|
|
606
592
|
const apiKey = engine.config.apiKey
|
|
607
|
-
|
|
593
|
+
// 自建本地服务(Ollama/llama.cpp/vLLM)无需 apiKey 也能拉取模型列表
|
|
594
|
+
const localServer = isLocalLlmServer(apiBase)
|
|
595
|
+
if (!apiKey && !localServer) { console.log('❌ API key not configured'); break }
|
|
608
596
|
const modelsUrl = apiBase.replace(/\/+$/, '') + '/models'
|
|
609
597
|
console.log(`📡 Fetching models from ${modelsUrl}...`)
|
|
610
598
|
try {
|
|
611
|
-
const res = await fetch(modelsUrl, {
|
|
599
|
+
const res = await fetch(modelsUrl, {
|
|
600
|
+
headers: {
|
|
601
|
+
'Content-Type': 'application/json',
|
|
602
|
+
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}),
|
|
603
|
+
},
|
|
604
|
+
})
|
|
612
605
|
if (!res.ok) { console.log(`❌ API error: ${res.status}`); break }
|
|
613
606
|
const data = await res.json()
|
|
614
607
|
const models = data.data || []
|
|
@@ -656,7 +649,6 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
656
649
|
}
|
|
657
650
|
const target = rest.join(' ')
|
|
658
651
|
let sessionId = target
|
|
659
|
-
// 支持数字序号选择(从 /sessions 列表中)
|
|
660
652
|
if (/^\d+$/.test(target)) {
|
|
661
653
|
const idx = parseInt(target, 10) - 1
|
|
662
654
|
const list = await sessionManager.list()
|
|
@@ -671,19 +663,16 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
671
663
|
console.log(`❌ Session not found: ${sessionId}`)
|
|
672
664
|
break
|
|
673
665
|
}
|
|
674
|
-
// 恢复会话内容
|
|
675
666
|
session.id = loaded.id
|
|
676
667
|
session.title = loaded.title
|
|
677
668
|
session.messages = loaded.messages
|
|
678
669
|
session.state = loaded.state || {}
|
|
679
|
-
// 恢复引擎消息历史
|
|
680
670
|
engine.state.messages = loaded.messages.map(m => ({
|
|
681
671
|
role: m.role,
|
|
682
672
|
content: m.content,
|
|
683
673
|
toolCalls: m.toolCalls,
|
|
684
674
|
toolCallId: m.toolCallId,
|
|
685
675
|
}))
|
|
686
|
-
// 恢复计数和预算
|
|
687
676
|
engine.state.turnCount = loaded.state?.turnCount || 0
|
|
688
677
|
if (engine.tokenBudget && loaded.state?.budgetUsed != null) {
|
|
689
678
|
engine.tokenBudget.used = loaded.state.budgetUsed
|
|
@@ -791,7 +780,7 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
791
780
|
default:
|
|
792
781
|
console.log(`Unknown command: /${cmd}. Type /help for available commands.`)
|
|
793
782
|
}
|
|
794
|
-
|
|
783
|
+
showPrompt()
|
|
795
784
|
return
|
|
796
785
|
}
|
|
797
786
|
|
|
@@ -803,13 +792,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
803
792
|
console.log()
|
|
804
793
|
await sessionManager.appendMessage({ role: 'user', content: input })
|
|
805
794
|
await sessionManager.appendMessage({ role: 'assistant', content: result.response })
|
|
806
|
-
// 保存引擎状态到会话
|
|
807
795
|
session.state = session.state || {}
|
|
808
796
|
session.state.turnCount = engine.state.turnCount
|
|
809
797
|
session.state.costHistory = engine.costTracker.history.slice(-50)
|
|
810
798
|
await sessionManager.save(session)
|
|
811
799
|
if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
|
|
812
|
-
// 显示费用(即使非 verbose 也显示)
|
|
813
800
|
if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
|
|
814
801
|
console.log(engine.costTracker.formatShort())
|
|
815
802
|
}
|
|
@@ -821,8 +808,72 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
821
808
|
}).catch(() => {})
|
|
822
809
|
}
|
|
823
810
|
}
|
|
824
|
-
|
|
811
|
+
showPrompt()
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
|
|
815
|
+
rl.on('line', (line) => {
|
|
816
|
+
processInputLine(line)
|
|
825
817
|
})
|
|
826
818
|
|
|
827
|
-
|
|
819
|
+
// 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
|
|
820
|
+
if (permissionMode === 'ask') {
|
|
821
|
+
engine.config.onConfirmTool = async (toolName, input) => {
|
|
822
|
+
// 如果已经启用会话全局自动允许,直接通过
|
|
823
|
+
if (engine.permissionChecker.sessionAllowAll) return true
|
|
824
|
+
|
|
825
|
+
const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
|
|
826
|
+
return new Promise((resolve) => {
|
|
827
|
+
rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N/a) a=all session `, (answer) => {
|
|
828
|
+
const a = answer.toLowerCase()
|
|
829
|
+
if (a === 'a') {
|
|
830
|
+
engine.permissionChecker.allowAllForSession()
|
|
831
|
+
resolve(true)
|
|
832
|
+
} else {
|
|
833
|
+
resolve(a === 'y')
|
|
834
|
+
}
|
|
835
|
+
})
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
engine.config.readline = rl
|
|
840
|
+
|
|
841
|
+
console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
|
|
842
|
+
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
843
|
+
console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
|
|
844
|
+
if (channelManager.list().length > 0) {
|
|
845
|
+
const chList = channelManager.list().join(', ')
|
|
846
|
+
const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
|
|
847
|
+
console.log(`Channels: ${chList}${def}`)
|
|
848
|
+
}
|
|
849
|
+
console.log()
|
|
850
|
+
// 显示初始提示符
|
|
851
|
+
showPrompt()
|
|
852
|
+
|
|
853
|
+
// REPL 消息处理包装(留作扩展点)
|
|
854
|
+
async function processInput(input) {
|
|
855
|
+
return engine.processMessage(input)
|
|
856
|
+
}
|
|
828
857
|
}
|
|
858
|
+
|
|
859
|
+
// ============================================================
|
|
860
|
+
// 全局退出处理 — 回收 stdin 的 raw mode
|
|
861
|
+
// ============================================================
|
|
862
|
+
function cleanupStdin() {
|
|
863
|
+
try {
|
|
864
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
865
|
+
process.stdin.removeAllListeners('keypress')
|
|
866
|
+
} catch {}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
process.on('SIGINT', () => {
|
|
870
|
+
cleanupStdin()
|
|
871
|
+
process.exit(0)
|
|
872
|
+
})
|
|
873
|
+
process.on('SIGTERM', () => {
|
|
874
|
+
cleanupStdin()
|
|
875
|
+
process.exit(0)
|
|
876
|
+
})
|
|
877
|
+
process.on('exit', () => {
|
|
878
|
+
cleanupStdin()
|
|
879
|
+
})
|
package/src/core/query-engine.js
CHANGED
|
@@ -17,6 +17,7 @@ import { parseStream, parseNonStreamResponse } from './streaming.js'
|
|
|
17
17
|
import { autoCompact } from './compact.js'
|
|
18
18
|
import { CostTracker } from './cost-tracker.js'
|
|
19
19
|
import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
|
|
20
|
+
import { isLocalLlmServer, buildAuthHeaders } from '../utils/index.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* 配置选项
|
|
@@ -265,7 +266,9 @@ export class QueryEngine {
|
|
|
265
266
|
const apiKey = this.config.apiKey
|
|
266
267
|
const apiBase = this.config.apiBase
|
|
267
268
|
|
|
268
|
-
|
|
269
|
+
// apiBase 指向自建本地服务(Ollama / llama.cpp / vLLM 等)时,允许缺省 apiKey
|
|
270
|
+
const isLocalServer = isLocalLlmServer(apiBase)
|
|
271
|
+
if (!isLocalServer && !apiKey) {
|
|
269
272
|
throw new Error(
|
|
270
273
|
`未设置 API Key。请设置以下环境变量之一:\n` +
|
|
271
274
|
` LLM_API_KEY=xxx (通用,推荐)\n` +
|
|
@@ -331,7 +334,7 @@ export class QueryEngine {
|
|
|
331
334
|
method: 'POST',
|
|
332
335
|
headers: {
|
|
333
336
|
'Content-Type': 'application/json',
|
|
334
|
-
|
|
337
|
+
...buildAuthHeaders(apiBase, apiKey),
|
|
335
338
|
},
|
|
336
339
|
body: JSON.stringify(body),
|
|
337
340
|
signal: this.abortController?.signal,
|
package/src/utils/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { unifiedDiff, inlineDiff } from './diff.js'
|
|
|
5
5
|
export { safeReadFile, safeWriteFile, editFile, pathExists, removeRecursive, getMtime, resolvePath } from './file-ops.js'
|
|
6
6
|
export { execCommand, spawnProcess, commandExists, sendInput } from './process.js'
|
|
7
7
|
export { format, codeBlock, formatPath, formatToolCall, formatToolResult, formatTokenUsage, formatDuration, formatBytes, formatTable, progressBar } from './format.js'
|
|
8
|
+
export { isLocalLlmServer, isLocalHostname, buildAuthHeaders } from './llm-server.js'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 服务器识别工具
|
|
3
|
+
*
|
|
4
|
+
* 用于判断一个 `--api-base` 是否是「自建本地 LLM 服务」。
|
|
5
|
+
* 自建服务(Ollama / llama.cpp / vLLM 等)通常运行在 localhost 或内网,
|
|
6
|
+
* 且默认【不需要 apiKey】(认证是可选配置)。
|
|
7
|
+
*
|
|
8
|
+
* 用途:当 apiBase 指向自建服务时,允许缺省 apiKey 运行,
|
|
9
|
+
* 并将旧的 `Authorization: Bearer undefined` 替换为不附带该头。
|
|
10
|
+
* 这解决了公共项目接入 Ollama / llama.cpp / vLLM 时被强制要求
|
|
11
|
+
* 假 apiKey 的问题。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// RFC1918 私有段 + 回环
|
|
15
|
+
const PRIVATE_PATTERNS = [
|
|
16
|
+
/^127\./, // 127.0.0.0/8 回环
|
|
17
|
+
/^10\./, // 10.0.0.0/8
|
|
18
|
+
/^192\.168\./, // 192.168.0.0/16
|
|
19
|
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
|
20
|
+
/^169\.254\./, // 链路本地
|
|
21
|
+
/^::1$/, // IPv6 回环
|
|
22
|
+
/^fc[0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
|
|
23
|
+
/^fe[89ab][0-9a-f]:/i, // IPv6 link-local fe80::/10
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// 明确视为「本地自建」的主机名关键词
|
|
27
|
+
const LOCAL_HOSTNAME_KEYWORDS = [
|
|
28
|
+
'localhost',
|
|
29
|
+
'127.0.0.1',
|
|
30
|
+
'::1',
|
|
31
|
+
'.local',
|
|
32
|
+
'.lan',
|
|
33
|
+
'.localdomain',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
let cachedHost = null
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 从 URL 中提取 host(含端口),便于可读性判断
|
|
40
|
+
*/
|
|
41
|
+
function parseUrl(apiBase) {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(apiBase)
|
|
44
|
+
} catch {
|
|
45
|
+
// 非标准 URL(可能缺 scheme),尝试补 http:// 再解析
|
|
46
|
+
try {
|
|
47
|
+
return new URL(apiBase.startsWith('http') ? apiBase : `http://${apiBase}`)
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 判断主机是否为「本地/内网/回环」地址
|
|
56
|
+
* @param {string} hostname - 无端口的主机名或 IP
|
|
57
|
+
*/
|
|
58
|
+
export function isLocalHostname(hostname) {
|
|
59
|
+
if (!hostname) return false
|
|
60
|
+
const h = hostname.toLowerCase()
|
|
61
|
+
|
|
62
|
+
// 主机名关键词
|
|
63
|
+
for (const kw of LOCAL_HOSTNAME_KEYWORDS) {
|
|
64
|
+
if (kw.startsWith('.') ? h.endsWith(kw) : (h === kw || h.startsWith(kw + '.'))) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// IPv4 / IPv6 私有段
|
|
70
|
+
for (const re of PRIVATE_PATTERNS) {
|
|
71
|
+
if (re.test(h)) return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 判断一个 apiBase 是否指向「自建本地 LLM 服务」(无需强制 apiKey)
|
|
79
|
+
* @param {string} apiBase - 如 http://192.168.1.50:11434/v1
|
|
80
|
+
*/
|
|
81
|
+
export function isLocalLlmServer(apiBase) {
|
|
82
|
+
if (!apiBase) return false
|
|
83
|
+
const url = parseUrl(apiBase)
|
|
84
|
+
if (!url || !url.hostname) return false
|
|
85
|
+
return isLocalHostname(url.hostname)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 计算请求应携带的 Authorization 头。
|
|
90
|
+
* - 若提供了 apiKey → 正常 Bearer
|
|
91
|
+
* - 若未提供 apiKey 且目标是【自建本地服务】 → 不附带该头(返回 null/undefined)
|
|
92
|
+
* - 若未提供 apiKey 且目标是【云端服务】 → 返回 null(由上层决定是否报错)
|
|
93
|
+
* @param {string} apiBase
|
|
94
|
+
* @param {string} apiKey
|
|
95
|
+
* @returns {{ Authorization: string } | undefined} 返回 undefined 表示不附带 Authorization 头
|
|
96
|
+
*/
|
|
97
|
+
export function buildAuthHeaders(apiBase, apiKey) {
|
|
98
|
+
if (apiKey) {
|
|
99
|
+
return { 'Authorization': `Bearer ${apiKey}` }
|
|
100
|
+
}
|
|
101
|
+
// 无 key:本地自建服务返回空对象(不带 Authorization),云端返回 undefined(保持不变,交由上层判断)
|
|
102
|
+
if (isLocalLlmServer(apiBase)) {
|
|
103
|
+
return {}
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|