@raolin2025/claude-code-node 2.5.1 → 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 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
- cc-node --model qwen2.5 --api-base http://localhost:11434/v1
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
  [![npm version](https://img.shields.io/npm/v/@raolin2025/claude-code-node.svg)](https://www.npmjs.com/package/@raolin2025/claude-code-node) [![GitHub](https://img.shields.io/badge/GitHub-bg1avd%2Fclaude--code--node-blue)](https://github.com/bg1avd/claude-code-node) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.5.1",
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 { createInterface } from 'readline'
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, { headers: { 'Authorization': `Bearer ${apiKeyForModels}` } })
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
- const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
545
-
546
- // 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
547
- if (permissionMode === 'ask') {
548
- engine.config.onConfirmTool = async (toolName, input) => {
549
- // 如果已经启用会话全局自动允许,直接通过
550
- if (engine.permissionChecker.sessionAllowAll) return true
551
-
552
- const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
553
- return new Promise((resolve) => {
554
- rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N/a) a=all session `, (answer) => {
555
- const a = answer.toLowerCase()
556
- if (a === 'a') {
557
- engine.permissionChecker.allowAllForSession()
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
- rl.on('line', async (line) => {
585
- const input = line.trim()
586
- if (!input) { rl.prompt(); return }
569
+ // 处理输入行
570
+ async function processInputLine(input) {
571
+ const trimmed = input.trim()
572
+ if (!trimmed) { showPrompt(); return }
587
573
 
588
- if (input.startsWith('/')) {
589
- const [cmd, ...rest] = input.slice(1).split(' ')
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
- if (!apiKey) { console.log('❌ API key not configured'); break }
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, { headers: { 'Authorization': `Bearer ${apiKey}` } })
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
- rl.prompt()
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
- rl.prompt()
811
+ showPrompt()
812
+ }
813
+
814
+ // REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
815
+ rl.on('line', (line) => {
816
+ processInputLine(line)
825
817
  })
826
818
 
827
- rl.on('close', () => { console.log('\nGoodbye!'); process.exit(0) })
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
+ })
@@ -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
- if (!apiKey) {
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
- 'Authorization': `Bearer ${apiKey}`,
337
+ ...buildAuthHeaders(apiBase, apiKey),
335
338
  },
336
339
  body: JSON.stringify(body),
337
340
  signal: this.abortController?.signal,
@@ -10,8 +10,8 @@
10
10
  * qqbot_media: { action: 'upload', path: '/home/.../image.png', scope: 'group', targetId: '群OPENID' }
11
11
  */
12
12
 
13
- import { QQBotEnhanced } from '../src/channel/qqbot-enhanced.js'
14
- import { parseQQMediaTags } from '../src/channel/qqbot-enhanced.js'
13
+ import { QQBotEnhanced } from '../channel/qqbot-enhanced.js'
14
+ import { parseQQMediaTags } from '../channel/qqbot-enhanced.js'
15
15
 
16
16
  const API_BASE = 'https://api.sgroup.qq.com'
17
17
 
@@ -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
+ }