coding-tool-x 3.5.13 → 3.5.14

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.
@@ -1,12 +1,10 @@
1
1
  const express = require('express');
2
2
  const { exec } = require('child_process');
3
- const { promisify } = require('util');
4
3
  const path = require('path');
5
4
  const fs = require('fs');
6
5
  const pm2 = require('pm2');
7
6
  const { HOME_DIR } = require('../../config/paths');
8
-
9
- const execAsync = promisify(exec);
7
+ const PM2_BIN_PATH = require.resolve('pm2/bin/pm2');
10
8
 
11
9
  function getExecOptions(timeout = 30000, runtimePlatform = process.platform) {
12
10
  if (runtimePlatform === 'win32') {
@@ -15,6 +13,46 @@ function getExecOptions(timeout = 30000, runtimePlatform = process.platform) {
15
13
  return { shell: '/bin/bash', timeout, windowsHide: true };
16
14
  }
17
15
 
16
+ function shellQuote(value) {
17
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
18
+ }
19
+
20
+ function getPm2CliCommand() {
21
+ return `${shellQuote(process.execPath)} ${shellQuote(PM2_BIN_PATH)}`;
22
+ }
23
+
24
+ function listMatchingFiles(dirPath, matcher) {
25
+ if (!fs.existsSync(dirPath)) {
26
+ return [];
27
+ }
28
+
29
+ return fs.readdirSync(dirPath)
30
+ .filter(fileName => matcher.test(fileName))
31
+ .map(fileName => path.join(dirPath, fileName));
32
+ }
33
+
34
+ function getStartupCommand(runtimePlatform = process.platform) {
35
+ const pm2Cli = getPm2CliCommand();
36
+ if (runtimePlatform === 'darwin') {
37
+ return `${pm2Cli} startup launchd -u $(whoami) --hp $(eval echo ~$(whoami))`;
38
+ }
39
+ if (runtimePlatform === 'linux') {
40
+ return `${pm2Cli} startup systemd -u $(whoami) --hp $(eval echo ~$(whoami))`;
41
+ }
42
+ return `${pm2Cli} startup`;
43
+ }
44
+
45
+ function getUnstartupCommand(runtimePlatform = process.platform) {
46
+ const pm2Cli = getPm2CliCommand();
47
+ if (runtimePlatform === 'darwin') {
48
+ return `${pm2Cli} unstartup launchd -u $(whoami)`;
49
+ }
50
+ if (runtimePlatform === 'linux') {
51
+ return `${pm2Cli} unstartup systemd -u $(whoami)`;
52
+ }
53
+ return `${pm2Cli} unstartup`;
54
+ }
55
+
18
56
  /**
19
57
  * Check if PM2 autostart is enabled
20
58
  * by looking for PM2 startup script in system
@@ -24,17 +62,21 @@ async function checkAutoStartStatus() {
24
62
  const platform = process.platform;
25
63
 
26
64
  if (platform === 'darwin') {
27
- // macOS - check for LaunchDaemon
28
- const launchDaemonsPath = path.join(HOME_DIR, 'Library/LaunchDaemons');
29
- const pm2Files = fs.existsSync(launchDaemonsPath)
30
- ? fs.readdirSync(launchDaemonsPath).filter(f => f.includes('pm2'))
31
- : [];
32
-
33
- return { enabled: pm2Files.length > 0, platform: 'darwin' };
65
+ const pm2Files = [
66
+ ...listMatchingFiles(path.join(HOME_DIR, 'Library/LaunchAgents'), /pm2/i),
67
+ ...listMatchingFiles(path.join(HOME_DIR, 'Library/LaunchDaemons'), /pm2/i),
68
+ ...listMatchingFiles('/Library/LaunchAgents', /pm2/i),
69
+ ...listMatchingFiles('/Library/LaunchDaemons', /pm2/i)
70
+ ];
71
+
72
+ return {
73
+ enabled: pm2Files.length > 0,
74
+ platform: 'darwin',
75
+ files: pm2Files
76
+ };
34
77
  } else if (platform === 'linux') {
35
78
  // Linux - check for systemd service
36
79
  const systemdPath = '/etc/systemd/system/pm2-root.service';
37
- const userSystemdPath = path.join(HOME_DIR, '.config/systemd/user/pm2-*.service');
38
80
 
39
81
  const rootExists = fs.existsSync(systemdPath);
40
82
  const userExists = fs.existsSync(path.join(HOME_DIR, '.config/systemd/user')) &&
@@ -102,12 +144,7 @@ async function enableAutoStart() {
102
144
  }
103
145
 
104
146
  // Run startup command
105
- const platform = process.platform;
106
- const command = platform === 'darwin'
107
- ? 'pm2 startup launchd -u $(whoami) --hp $(eval echo ~$(whoami))'
108
- : platform === 'linux'
109
- ? 'pm2 startup systemd -u $(whoami) --hp $(eval echo ~$(whoami))'
110
- : 'pm2 startup';
147
+ const command = getStartupCommand();
111
148
 
112
149
  console.log(`Running startup command: ${command}`);
113
150
 
@@ -161,12 +198,7 @@ async function disableAutoStart() {
161
198
  }
162
199
 
163
200
  // Run unstartup command
164
- const platform = process.platform;
165
- const command = platform === 'darwin'
166
- ? 'pm2 unstartup launchd -u $(whoami)'
167
- : platform === 'linux'
168
- ? 'pm2 unstartup systemd -u $(whoami)'
169
- : 'pm2 unstartup';
201
+ const command = getUnstartupCommand();
170
202
 
171
203
  console.log(`Running unstartup command: ${command}`);
172
204
 
@@ -277,5 +309,8 @@ function createPm2AutostartRouter() {
277
309
 
278
310
  module.exports = createPm2AutostartRouter;
279
311
  module.exports._test = {
280
- getExecOptions
312
+ getExecOptions,
313
+ getPm2CliCommand,
314
+ getStartupCommand,
315
+ getUnstartupCommand
281
316
  };
@@ -19,8 +19,15 @@ const eventBus = require('../plugins/event-bus');
19
19
  const { getEffectiveApiKey } = require('./services/channels');
20
20
  const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('./services/request-logger');
21
21
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
22
- const { redirectModel } = require('./services/base/proxy-utils');
23
- const { parseSSEUsage, mergeUsageIntoTokenData, createTokenData } = require('./services/base/response-usage-parser');
22
+ const { redirectModel, normalizeGatewaySourceType } = require('./services/base/proxy-utils');
23
+ const { parseSSEUsage, parseNonStreamingUsage, mergeUsageIntoTokenData, createTokenData } = require('./services/base/response-usage-parser');
24
+ const {
25
+ createClaudeStreamRecoveryState,
26
+ mergeClaudeStreamEvent,
27
+ buildAssistantMessageFromStreamState,
28
+ recoverClaudeUsageViaCountTokens
29
+ } = require('./services/claude-token-recovery');
30
+ const { handleClaudeOpenAiGatewayRequest } = require('./services/claude-openai-gateway');
24
31
  const { attachServerShutdownHandling, expediteServerShutdown } = require('./services/server-shutdown');
25
32
 
26
33
  let proxyServer = null;
@@ -172,6 +179,16 @@ function buildClaudeRequestSummary(req, sessionId = null) {
172
179
  };
173
180
  }
174
181
 
182
+ function clearTokenDataUsage(tokenData) {
183
+ tokenData.inputTokens = 0;
184
+ tokenData.outputTokens = 0;
185
+ tokenData.cacheCreation = 0;
186
+ tokenData.cacheRead = 0;
187
+ tokenData.cachedTokens = 0;
188
+ tokenData.reasoningTokens = 0;
189
+ tokenData.totalTokens = 0;
190
+ }
191
+
175
192
  async function startProxyServer(options = {}) {
176
193
  const preserveStartTime = options.preserveStartTime || false;
177
194
 
@@ -314,6 +331,21 @@ async function startProxyServer(options = {}) {
314
331
  }
315
332
  }
316
333
 
334
+ const gatewaySourceType = normalizeGatewaySourceType(channel.gatewaySourceType, 'claude');
335
+ if (gatewaySourceType === 'openai_compatible') {
336
+ const handled = await handleClaudeOpenAiGatewayRequest({
337
+ req,
338
+ res,
339
+ channel,
340
+ effectiveKey,
341
+ calculateCost,
342
+ onDone: release
343
+ });
344
+ if (handled) {
345
+ return;
346
+ }
347
+ }
348
+
317
349
  const proxyOptions = {
318
350
  target: channel.baseUrl,
319
351
  changeOrigin: true,
@@ -396,9 +428,16 @@ async function startProxyServer(options = {}) {
396
428
  requestMetadata.delete(req);
397
429
  });
398
430
 
431
+ const isSseResponse = proxyRes.headers['content-type']?.includes('text/event-stream');
399
432
  let buffer = '';
400
433
  let tokenData = createTokenData();
401
434
  let usageRecorded = false;
435
+ const streamRecoveryState = createClaudeStreamRecoveryState();
436
+ const usageObservation = {
437
+ sawAnyUsage: false,
438
+ sawMessageStartUsage: false,
439
+ sawNonInitialUsage: false
440
+ };
402
441
  const parsedStream = createDecodedStream(proxyRes);
403
442
 
404
443
  function recordUsageIfReady() {
@@ -434,6 +473,10 @@ async function startProxyServer(options = {}) {
434
473
 
435
474
  buffer += chunk.toString('utf8');
436
475
 
476
+ if (!isSseResponse) {
477
+ return;
478
+ }
479
+
437
480
  const events = buffer.split('\n\n');
438
481
  buffer = events.pop() || '';
439
482
 
@@ -456,10 +499,19 @@ async function startProxyServer(options = {}) {
456
499
  if (!data || data === '[DONE]') return;
457
500
 
458
501
  const parsed = JSON.parse(data);
502
+ mergeClaudeStreamEvent(streamRecoveryState, eventType, parsed);
459
503
  const usage = parseSSEUsage(parsed, eventType);
504
+ if (usage.tokens) {
505
+ usageObservation.sawAnyUsage = true;
506
+ if (eventType === 'message_start') {
507
+ usageObservation.sawMessageStartUsage = true;
508
+ } else {
509
+ usageObservation.sawNonInitialUsage = true;
510
+ }
511
+ }
460
512
  mergeUsageIntoTokenData(tokenData, usage);
461
513
 
462
- if (usage.isDone) {
514
+ if (usage.isDone && (!isSseResponse || usageObservation.sawNonInitialUsage)) {
463
515
  recordUsageIfReady();
464
516
  }
465
517
  } catch (err) {
@@ -477,8 +529,48 @@ async function startProxyServer(options = {}) {
477
529
  };
478
530
 
479
531
  parsedStream.on('end', () => {
480
- recordUsageIfReady();
481
- finalize();
532
+ void (async () => {
533
+ if (!isSseResponse) {
534
+ try {
535
+ const parsed = JSON.parse(buffer);
536
+ const usage = parseNonStreamingUsage(parsed);
537
+ mergeUsageIntoTokenData(tokenData, usage);
538
+ } catch (err) {
539
+ }
540
+ } else {
541
+ const assistantMessage = buildAssistantMessageFromStreamState(streamRecoveryState);
542
+ const shouldRecoverUsage = Boolean(assistantMessage)
543
+ && (!usageObservation.sawAnyUsage || (usageObservation.sawMessageStartUsage && !usageObservation.sawNonInitialUsage));
544
+
545
+ if (shouldRecoverUsage) {
546
+ try {
547
+ const recoveredUsage = await recoverClaudeUsageViaCountTokens({
548
+ baseUrl: req.selectedChannel?.baseUrl || '',
549
+ apiKey: req.effectiveApiKey || '',
550
+ requestBody: req.body,
551
+ assistantMessage
552
+ });
553
+ if (recoveredUsage) {
554
+ tokenData.model = tokenData.model || streamRecoveryState.model || '';
555
+ tokenData.inputTokens = Number(recoveredUsage.inputTokens || 0);
556
+ tokenData.outputTokens = Number(recoveredUsage.outputTokens || 0);
557
+ // Let publishUsageLog recompute Claude totals from the recovered
558
+ // prompt/output pair plus any cache fields already present.
559
+ tokenData.totalTokens = 0;
560
+ } else {
561
+ tokenData.model = tokenData.model || streamRecoveryState.model || '';
562
+ clearTokenDataUsage(tokenData);
563
+ }
564
+ } catch (error) {
565
+ tokenData.model = tokenData.model || streamRecoveryState.model || '';
566
+ clearTokenDataUsage(tokenData);
567
+ }
568
+ }
569
+ }
570
+
571
+ recordUsageIfReady();
572
+ finalize();
573
+ })();
482
574
  });
483
575
 
484
576
  parsedStream.on('error', (err) => {
@@ -5,6 +5,7 @@ const { isProxyConfig } = require('./settings-manager');
5
5
  const { PATHS, NATIVE_PATHS } = require('../../config/paths');
6
6
  const { clearNativeOAuth } = require('./native-oauth-adapters');
7
7
  const { isWindowsLikePlatform } = require('../../utils/home-dir');
8
+ const { normalizeGatewaySourceType } = require('./base/proxy-utils');
8
9
 
9
10
  // ── Claude 特有工具函数 ──
10
11
 
@@ -160,6 +161,27 @@ function resolveCurrentManagedChannel(channels = []) {
160
161
  return allChannels.find(ch => ch.enabled !== false) || null;
161
162
  }
162
163
 
164
+ function normalizeClaudeTargetApi(value) {
165
+ const normalized = String(value || '').trim().toLowerCase();
166
+ if (normalized === 'chat' || normalized === 'chat/completions' || normalized === 'chat.completions') {
167
+ return 'chat.completions';
168
+ }
169
+ return 'responses';
170
+ }
171
+
172
+ function isOfficialOpenAiBaseUrl(baseUrl = '') {
173
+ try {
174
+ const parsed = new URL(String(baseUrl || '').trim());
175
+ return String(parsed.hostname || '').trim().toLowerCase() === 'api.openai.com';
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ function isOpenAiCompatibleGateway(channel) {
182
+ return normalizeGatewaySourceType(channel?.gatewaySourceType, 'claude') === 'openai_compatible';
183
+ }
184
+
163
185
  // ── ClaudeChannelService ──
164
186
 
165
187
  class ClaudeChannelService extends BaseChannelService {
@@ -179,6 +201,20 @@ class ClaudeChannelService extends BaseChannelService {
179
201
  return `channel-${Date.now()}`;
180
202
  }
181
203
 
204
+ _applyDefaults(channel) {
205
+ const normalized = super._applyDefaults(channel);
206
+ normalized.presetId = normalized.presetId || 'official';
207
+ normalized.modelConfig = normalized.modelConfig || {};
208
+ normalized.modelRedirects = Array.isArray(normalized.modelRedirects) ? normalized.modelRedirects : [];
209
+ normalized.proxyUrl = normalized.proxyUrl || '';
210
+ normalized.speedTestModel = normalized.speedTestModel || null;
211
+ normalized.targetApi = normalizeClaudeTargetApi(normalized.targetApi);
212
+ if (isOpenAiCompatibleGateway(normalized) && !isOfficialOpenAiBaseUrl(normalized.baseUrl)) {
213
+ normalized.targetApi = 'chat.completions';
214
+ }
215
+ return normalized;
216
+ }
217
+
182
218
  // Claude 使用缓存 + fs.watchFile
183
219
  loadChannels() {
184
220
  if (this._cacheInitialized && this._cache) {
@@ -209,7 +245,7 @@ class ClaudeChannelService extends BaseChannelService {
209
245
  }
210
246
 
211
247
  _onAfterCreate(channel, _allChannels) {
212
- if (!isProxyConfig() && channel.enabled !== false) {
248
+ if (!isProxyConfig() && channel.enabled !== false && !isOpenAiCompatibleGateway(channel)) {
213
249
  this._applyToNativeSettings(channel);
214
250
  }
215
251
  }
@@ -220,12 +256,18 @@ class ClaudeChannelService extends BaseChannelService {
220
256
  }
221
257
 
222
258
  if (oldChannel.enabled === false && nextChannel.enabled !== false) {
223
- this._applyToNativeSettings(nextChannel);
259
+ if (!isOpenAiCompatibleGateway(nextChannel)) {
260
+ this._applyToNativeSettings(nextChannel);
261
+ }
224
262
  return;
225
263
  }
226
264
 
227
265
  const activeChannel = resolveCurrentManagedChannel(allChannels);
228
- if (nextChannel.enabled !== false && activeChannel?.id === nextChannel.id) {
266
+ if (
267
+ nextChannel.enabled !== false
268
+ && activeChannel?.id === nextChannel.id
269
+ && !isOpenAiCompatibleGateway(nextChannel)
270
+ ) {
229
271
  this._applyToNativeSettings(nextChannel);
230
272
  }
231
273
  }
@@ -236,11 +278,27 @@ class ClaudeChannelService extends BaseChannelService {
236
278
  }
237
279
 
238
280
  const activeChannel = resolveCurrentManagedChannel(allChannels);
239
- if (activeChannel) {
281
+ if (activeChannel && !isOpenAiCompatibleGateway(activeChannel)) {
240
282
  this._applyToNativeSettings(activeChannel);
241
283
  }
242
284
  }
243
285
 
286
+ applyChannelToSettings(channelId) {
287
+ const data = this.loadChannels();
288
+ const channel = data.channels.find(ch => ch.id === channelId);
289
+ if (!channel) {
290
+ throw new Error('Channel not found');
291
+ }
292
+
293
+ if (isOpenAiCompatibleGateway(channel)) {
294
+ const error = new Error('OpenAI 格式渠道需要通过 Claude 代理使用,请先启动代理。');
295
+ error.statusCode = 400;
296
+ throw error;
297
+ }
298
+
299
+ return super.applyChannelToSettings(channelId);
300
+ }
301
+
244
302
  _applyToNativeSettings(channel) {
245
303
  updateClaudeSettingsWithModelConfig(channel);
246
304
  }