coding-tool-x 3.7.12 → 3.7.13

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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  该项目遵循 [Semantic Versioning](https://semver.org/)。
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+ - **OMP 渠道失败不再冻结** - OMP 动态切换开启后,失败渠道不再进入冻结状态。OMP 网关按请求即时回退其他渠道,冻结会导致短暂故障的渠道在恢复前被排除在后续分配之外
11
+ - **OMP 动态切换凭据稳定** - 同步当前 OMP 渠道时不再把动态切换生成的本地网关 `base_url` 和 capability API Key 覆盖真实渠道配置;同一渠道的受管 provider 生成值现在复用持久化网关密钥保持稳定
12
+
7
13
  ## [3.6.7] - 2026-05-16
8
14
 
9
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-tool-x",
3
- "version": "3.7.12",
3
+ "version": "3.7.13",
4
4
  "description": "Vibe Coding 增强工作助手 - 智能会话管理、动态渠道切换、全局搜索、实时监控",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -447,6 +447,7 @@ const PATHS = {
447
447
  opencode: path.join(RUNTIME_DIR, 'opencode-proxy.json'),
448
448
  omp: path.join(RUNTIME_DIR, 'omp-proxy.json')
449
449
  },
450
+ ompGatewaySecret: path.join(RUNTIME_DIR, 'omp-gateway-secret'),
450
451
 
451
452
  // 请求记录
452
453
  requestSnapshots: {
@@ -1,4 +1,3 @@
1
- const crypto = require('crypto');
2
1
  const { loadConfig } = require('../config/loader');
3
2
  const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
4
3
  const {
@@ -10,7 +9,8 @@ const {
10
9
  enableManagedOmpMode,
11
10
  disableManagedOmpMode,
12
11
  loadManagedOmpActiveChannelId,
13
- loadManagedOmpModeState
12
+ loadManagedOmpModeState,
13
+ getOrCreateOmpGatewaySecret
14
14
  } = require('./services/omp-channels');
15
15
  const {
16
16
  startOmpSessionLogObserver,
@@ -52,7 +52,7 @@ async function startOmpProxyServerUnlocked(options = {}) {
52
52
  activeChannelId: loadManagedOmpActiveChannelId()
53
53
  };
54
54
  const channels = getEnabledChannels();
55
- const secret = crypto.randomBytes(32).toString('hex');
55
+ const secret = getOrCreateOmpGatewaySecret();
56
56
  const desiredPort = options.port ?? config.ports?.ompProxy ?? 20092;
57
57
  const gatewayStatus = await gateway.start({
58
58
  host: '127.0.0.1',
@@ -143,6 +143,11 @@ function recordFailure(channelId, source = 'claude', error) {
143
143
  health.consecutiveSuccesses = 0;
144
144
  health.lastCheckTime = now;
145
145
 
146
+ // omp 动态切换按请求即时回退其他渠道,失败渠道无需冻结(冻结会将其排除在后续分配之外)
147
+ if (source === 'omp') {
148
+ return;
149
+ }
150
+
146
151
  // 如果当前是健康状态或检测中状态,检查是否需要冻结
147
152
  if (health.status === 'healthy' || health.status === 'checking') {
148
153
  if (health.consecutiveFailures >= healthConfig.failureThreshold) {
@@ -289,7 +289,7 @@ function upsertSyncedChannels({
289
289
  result.warnings.push(`${toolType}: missing baseUrl, skipped current channel sync`);
290
290
  continue;
291
291
  }
292
- if (!normalizeString(candidate.apiKey)) {
292
+ if (!normalizeString(candidate.apiKey) && candidate.authMode !== 'none') {
293
293
  result.skipped += 1;
294
294
  result.warnings.push(`${toolType}: missing API key or OAuth-only config, skipped current channel sync`);
295
295
  continue;
@@ -1,3 +1,4 @@
1
+ const crypto = require('crypto');
1
2
  const fs = require('fs');
2
3
  const path = require('path');
3
4
  const { execFileSync } = require('child_process');
@@ -15,6 +16,7 @@ const {
15
16
  } = require('./omp-settings-manager');
16
17
  const {
17
18
  createSkippedResult,
19
+ isLocalProxyBaseUrl,
18
20
  resolveApiKeyValue,
19
21
  resolveExistingActiveChannel,
20
22
  upsertSyncedChannels
@@ -74,6 +76,54 @@ function writeManagedOmpModeState(filePath, state) {
74
76
  }
75
77
  }
76
78
 
79
+ function getOmpGatewaySecretPath() {
80
+ return PATHS.ompGatewaySecret
81
+ || path.join(path.dirname(PATHS.activeChannel.omp), 'omp-gateway-secret');
82
+ }
83
+
84
+ function writeOmpGatewaySecret(filePath, secret) {
85
+ const dir = path.dirname(filePath);
86
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
87
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
88
+ try {
89
+ fs.writeFileSync(temporaryPath, `${secret}\n`, { encoding: 'utf8', mode: 0o600 });
90
+ fs.chmodSync(temporaryPath, 0o600);
91
+ fs.renameSync(temporaryPath, filePath);
92
+ } catch (error) {
93
+ if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ function getOrCreateOmpGatewaySecret() {
99
+ ensureStorageDirMigrated();
100
+ const filePath = getOmpGatewaySecretPath();
101
+ const previousStateSecret = loadManagedOmpModeState()?.gateway?.secret;
102
+ const fileExists = fs.existsSync(filePath);
103
+
104
+ if (fileExists) {
105
+ try {
106
+ const stored = fs.readFileSync(filePath, 'utf8').trim();
107
+ if (stored) return stored;
108
+ } catch (error) {
109
+ if (previousStateSecret) {
110
+ writeOmpGatewaySecret(filePath, previousStateSecret);
111
+ return previousStateSecret;
112
+ }
113
+ throw new Error(`Failed to read persisted OMP gateway secret: ${error.message}`);
114
+ }
115
+ if (!previousStateSecret) {
116
+ throw new Error('Persisted OMP gateway secret is empty');
117
+ }
118
+ writeOmpGatewaySecret(filePath, previousStateSecret);
119
+ return previousStateSecret;
120
+ }
121
+
122
+ const secret = String(previousStateSecret || crypto.randomBytes(32).toString('hex'));
123
+ writeOmpGatewaySecret(filePath, secret);
124
+ return secret;
125
+ }
126
+
77
127
  function enableManagedOmpMode(activeChannelId = null, gateway = null) {
78
128
  ensureStorageDirMigrated();
79
129
  const filePath = PATHS.activeChannel.omp;
@@ -267,6 +317,24 @@ function getOriginalOmpProviderId(providerId = '') {
267
317
  return normalized.startsWith('ctx-') ? normalized.slice(4) : normalized;
268
318
  }
269
319
 
320
+ function isManagedOmpProviderId(providerId = '') {
321
+ return String(providerId || '').trim().startsWith('ctx-');
322
+ }
323
+
324
+ function isGeneratedOmpApiKey(value = '') {
325
+ return /^ctx_[a-f0-9]{40}$/i.test(String(value || '').trim());
326
+ }
327
+
328
+ function isGeneratedOmpBaseUrl(value = '') {
329
+ const raw = String(value || '').trim();
330
+ if (!isLocalProxyBaseUrl(raw)) return false;
331
+ try {
332
+ return /^\/omp\/[a-f0-9]{24}(?:\/|$)/i.test(new URL(raw).pathname);
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
270
338
  function normalizeOmpModelId(value = '') {
271
339
  return String(value || '').trim().replace(OMP_THINKING_SUFFIX_RE, '');
272
340
  }
@@ -355,6 +423,28 @@ function collectOmpSyncSelections(modelsConfig = {}, settings = {}) {
355
423
  return [...selections.values()];
356
424
  }
357
425
 
426
+ function findOmpOriginalProvider(providers = {}, providerId = '') {
427
+ const normalizedProviderId = normalizeProviderId(getOriginalOmpProviderId(providerId));
428
+ const candidates = Object.entries(providers)
429
+ .filter(([key]) => !isManagedOmpProviderId(key))
430
+ .map(([id, provider]) => ({
431
+ id,
432
+ provider,
433
+ normalizedId: normalizeProviderId(id)
434
+ }));
435
+ const exactMatches = candidates.filter(item => item.normalizedId === normalizedProviderId);
436
+ if (exactMatches.length === 1) return exactMatches[0];
437
+ if (exactMatches.length > 1) return null;
438
+
439
+ const prefixMatches = candidates
440
+ .filter(item => normalizedProviderId.startsWith(`${item.normalizedId}-`))
441
+ .sort((left, right) => right.normalizedId.length - left.normalizedId.length);
442
+ if (prefixMatches.length === 0) return null;
443
+ const longestLength = prefixMatches[0].normalizedId.length;
444
+ const longestMatches = prefixMatches.filter(item => item.normalizedId.length === longestLength);
445
+ return longestMatches.length === 1 ? longestMatches[0] : null;
446
+ }
447
+
358
448
  function findOmpExistingChannel(channels = [], providerId = '', baseUrl = '') {
359
449
  const rawProvider = String(providerId || '').trim();
360
450
  const originalProvider = getOriginalOmpProviderId(rawProvider);
@@ -444,9 +534,18 @@ function readOmpApiKeyCredential(providerId = '') {
444
534
  return { value: '', source: '', envName: '' };
445
535
  }
446
536
 
537
+ function resolveOmpAuthMode(provider = {}, sourceProvider = {}, existing = null) {
538
+ const rawAuth = String(sourceProvider.auth || provider.auth || '').trim().toLowerCase();
539
+ if (rawAuth === 'none') return 'none';
540
+ if (existing?.authMode === 'none') return 'none';
541
+ if (existing?.authMode === 'oauth') return 'oauth';
542
+ return 'api_key';
543
+ }
544
+
447
545
  function buildOmpSyncCandidate(modelsConfig, selection, channels) {
546
+ const providers = getOmpProviders(modelsConfig);
448
547
  const providerId = selection?.providerId || '';
449
- const provider = modelsConfig?.providers?.[providerId] || null;
548
+ const provider = providers[providerId] || null;
450
549
  if (!provider || typeof provider !== 'object') {
451
550
  return {
452
551
  skip: true,
@@ -454,14 +553,42 @@ function buildOmpSyncCandidate(modelsConfig, selection, channels) {
454
553
  };
455
554
  }
456
555
 
457
- const baseUrl = String(provider.baseUrl || provider.base_url || '').trim();
458
- const originalProviderId = getOriginalOmpProviderId(providerId);
459
- const existing = findOmpExistingChannel(channels, providerId, baseUrl);
460
- let credential = resolveApiKeyValue(provider.apiKey || provider.api_key || '');
556
+ const managedProvider = isManagedOmpProviderId(providerId);
557
+ const originalProviderEntry = managedProvider
558
+ ? findOmpOriginalProvider(providers, providerId)
559
+ : null;
560
+ const sourceProvider = originalProviderEntry?.provider || (managedProvider ? {} : provider);
561
+ const originalProviderId = originalProviderEntry?.id || getOriginalOmpProviderId(providerId);
562
+ const providerBaseUrl = String(sourceProvider.baseUrl || sourceProvider.base_url || '').trim();
563
+ const managedBaseUrl = String(provider.baseUrl || provider.base_url || '').trim();
564
+ const existing = findOmpExistingChannel(
565
+ channels,
566
+ originalProviderId || providerId,
567
+ managedProvider ? '' : managedBaseUrl
568
+ );
569
+ const baseUrl = managedProvider && (
570
+ !providerBaseUrl || isGeneratedOmpBaseUrl(providerBaseUrl)
571
+ )
572
+ ? existing?.baseUrl || ''
573
+ : providerBaseUrl || existing?.baseUrl || managedBaseUrl;
574
+ const authMode = resolveOmpAuthMode(provider, sourceProvider, existing);
575
+
576
+ let credential = resolveApiKeyValue(sourceProvider.apiKey || sourceProvider.api_key || '');
461
577
  if (!credential.value) {
462
- credential = readOmpApiKeyCredential(providerId);
578
+ credential = readOmpApiKeyCredential(originalProviderId || providerId);
579
+ }
580
+ const existingCredential = resolveApiKeyValue(existing?.apiKey || '');
581
+ const apiKey = credential.value && !isGeneratedOmpApiKey(credential.value)
582
+ ? credential.value
583
+ : existingCredential.value && !isGeneratedOmpApiKey(existingCredential.value)
584
+ ? existingCredential.value
585
+ : '';
586
+ if (!credential.value || isGeneratedOmpApiKey(credential.value)) {
587
+ credential = existingCredential.value && !isGeneratedOmpApiKey(existingCredential.value)
588
+ ? { ...existingCredential, source: 'existing-channel' }
589
+ : credential;
463
590
  }
464
- const apiKey = credential.value || existing?.apiKey || '';
591
+
465
592
  const providerModelIds = collectOmpModelIds(provider);
466
593
  const selectedModelIds = Array.isArray(selection?.modelIds) ? selection.modelIds : [];
467
594
  const allowedModels = selectedModelIds.length > 0 ? selectedModelIds : providerModelIds;
@@ -470,11 +597,11 @@ function buildOmpSyncCandidate(modelsConfig, selection, channels) {
470
597
  || existing?.model
471
598
  || allowedModels[0]
472
599
  || null;
473
- if (!apiKey) {
600
+ if (!baseUrl || (authMode !== 'none' && !apiKey)) {
474
601
  return {
475
602
  skip: true,
476
603
  channel: existing || null,
477
- warning: `OMP 当前 provider "${providerId}" 缺少可解析 API Key,OAuth/登录态渠道不支持同步导入。`
604
+ warning: `OMP 当前 provider "${providerId}" 缺少可解析的上游 Base URL 或 API Key,OAuth/登录态渠道不支持同步导入。`
478
605
  };
479
606
  }
480
607
 
@@ -482,11 +609,11 @@ function buildOmpSyncCandidate(modelsConfig, selection, channels) {
482
609
  name: existing?.name || originalProviderId || providerId,
483
610
  providerKey: originalProviderId || providerId,
484
611
  baseUrl,
485
- apiKey,
486
- providerApi: normalizeProviderApi(provider.api),
487
- wireApi: provider.api || 'openai-completions',
488
- authMode: 'api_key',
489
- oauthProviderId: '',
612
+ apiKey: authMode === 'none' ? '' : apiKey,
613
+ providerApi: normalizeProviderApi(sourceProvider.api || provider.api),
614
+ wireApi: sourceProvider.api || provider.api || 'openai-completions',
615
+ authMode,
616
+ oauthProviderId: authMode === 'oauth' ? (existing?.oauthProviderId || originalProviderId || providerId) : '',
490
617
  model: preferredModel,
491
618
  allowedModels,
492
619
  models: filterOmpProviderModels(provider.models, allowedModels),
@@ -552,6 +679,7 @@ module.exports = {
552
679
  syncManagedProviderExtension: (channels) => service.syncManagedProviderExtension(channels),
553
680
  disableManagedProviderExtension: () => service.disableManagedProviderExtension(),
554
681
  isManagedOmpModeEnabled,
682
+ getOrCreateOmpGatewaySecret,
555
683
  enableManagedOmpMode,
556
684
  disableManagedOmpMode,
557
685
  loadManagedOmpModeState,