coding-tool-x 3.7.9 → 3.7.11

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.
Files changed (33) hide show
  1. package/dist/web/assets/{Analytics-D14GGF2y.js → Analytics-Ck_00Brj.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-C0TeUSZg.js → ConfigTemplates-Cpw2vUa8.js} +1 -1
  3. package/dist/web/assets/Home-Bft3Kztl.js +1 -0
  4. package/dist/web/assets/Home-CHZJkkjH.css +1 -0
  5. package/dist/web/assets/{PluginManager-DPZvPrT4.js → PluginManager-2RlvEJCT.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DUaiCvsF.js → ProjectList-_ecCngpl.js} +1 -1
  7. package/dist/web/assets/{SessionList-XdHNM61o.js → SessionList-DlWcw3hO.js} +1 -1
  8. package/dist/web/assets/{SkillManager-I4LFlsVN.js → SkillManager-CnY7IXkX.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-zMI7Lhzf.js → WorkspaceManager-3mqghi09.js} +1 -1
  10. package/dist/web/assets/{index-C5nLTbOk.css → index-BceXgSSe.css} +1 -1
  11. package/dist/web/assets/index-k5oP7c-A.js +2 -0
  12. package/dist/web/index.html +2 -2
  13. package/package.json +1 -1
  14. package/src/commands/toggle-proxy.js +7 -8
  15. package/src/commands/ui.js +11 -1
  16. package/src/config/default.js +1 -1
  17. package/src/index.js +41 -15
  18. package/src/server/api/omp-channels.js +16 -5
  19. package/src/server/api/omp-config.js +1 -1
  20. package/src/server/api/omp-proxy.js +13 -42
  21. package/src/server/index.js +3 -3
  22. package/src/server/omp-proxy-server.js +129 -10
  23. package/src/server/services/channel-scheduler.js +59 -9
  24. package/src/server/services/omp-auth-gateway-client.js +62 -0
  25. package/src/server/services/omp-channels.js +109 -12
  26. package/src/server/services/omp-gateway-routing.js +295 -0
  27. package/src/server/services/omp-gateway.js +919 -0
  28. package/src/server/services/omp-session-log-observer.js +138 -0
  29. package/src/server/services/omp-sessions.js +80 -0
  30. package/src/server/services/omp-settings-manager.js +358 -89
  31. package/dist/web/assets/Home-CUh_BsGm.js +0 -1
  32. package/dist/web/assets/Home-CqnBXFxj.css +0 -1
  33. package/dist/web/assets/index-CCP7sRMP.js +0 -2
@@ -101,10 +101,35 @@ function refreshChannels(source = 'claude') {
101
101
  });
102
102
  }
103
103
 
104
- function getAvailableChannels(source = 'claude') {
104
+ function matchesAllocationRequest(channel, options = {}) {
105
+ const candidateIds = Array.isArray(options.candidateIds)
106
+ ? new Set(options.candidateIds.filter(Boolean))
107
+ : null;
108
+ const excludedIds = new Set(
109
+ Array.isArray(options.excludeChannelIds) ? options.excludeChannelIds.filter(Boolean) : []
110
+ );
111
+ if (candidateIds && !candidateIds.has(channel.id)) return false;
112
+ if (excludedIds.has(channel.id)) return false;
113
+ if (options.routingGroup && String(channel.routingGroup || channel.id) !== String(options.routingGroup)) {
114
+ return false;
115
+ }
116
+ if (options.providerKey && String(channel.providerKey || channel.provider || '') !== String(options.providerKey)) {
117
+ return false;
118
+ }
119
+ if (
120
+ options.providerApi
121
+ && String(channel.providerApi || channel.api || channel.wireApi || 'openai-completions') !== String(options.providerApi)
122
+ ) {
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+
128
+ function getAvailableChannels(source = 'claude', options = {}) {
105
129
  refreshChannels(source);
106
130
  const state = getState(source);
107
131
  return state.channels.filter(ch => {
132
+ if (!matchesAllocationRequest(ch, options)) return false;
108
133
  if (!isChannelAvailable(ch.id, source)) {
109
134
  return false;
110
135
  }
@@ -132,7 +157,7 @@ function tryAllocate(source = 'claude', options = {}) {
132
157
  const state = getState(source);
133
158
  const sessionId = options.sessionId;
134
159
  const enableSessionBinding = options.enableSessionBinding !== false; // 默认开启
135
- const available = getAvailableChannels(source);
160
+ const available = getAvailableChannels(source, options);
136
161
  if (!available.length) {
137
162
  return null;
138
163
  }
@@ -169,6 +194,7 @@ function drainQueue(source = 'claude') {
169
194
  if (channel) {
170
195
  clearTimeout(entry.timer);
171
196
  state.queue.splice(i, 1);
197
+ entry.cleanup?.();
172
198
  entry.resolve(channel);
173
199
  return drainQueue(source);
174
200
  }
@@ -178,6 +204,9 @@ function drainQueue(source = 'claude') {
178
204
  function allocateChannel(options = {}) {
179
205
  const source = options.source || 'claude';
180
206
  const state = getState(source);
207
+ if (options.signal?.aborted) {
208
+ return Promise.reject(Object.assign(new Error('Channel allocation aborted'), { name: 'AbortError' }));
209
+ }
181
210
  const channel = tryAllocate(source, options);
182
211
  if (channel) {
183
212
  return Promise.resolve(channel);
@@ -186,16 +215,31 @@ function allocateChannel(options = {}) {
186
215
  if (!state.channels.length) {
187
216
  return Promise.reject(new Error('暂无可用渠道,请先添加并启用至少一个渠道'));
188
217
  }
218
+ if (!state.channels.some(ch => matchesAllocationRequest(ch, options))) {
219
+ return Promise.reject(new Error('没有匹配当前请求的可用渠道'));
220
+ }
189
221
 
190
222
  // 检查是否所有渠道都被冻结
191
223
  const allFrozen = state.channels.every(ch => !isChannelAvailable(ch.id, source));
192
224
 
193
225
  return new Promise((resolve, reject) => {
226
+ let entry;
227
+ const cleanup = () => {
228
+ options.signal?.removeEventListener('abort', onAbort);
229
+ };
230
+ const removeEntry = () => {
231
+ const index = state.queue.indexOf(entry);
232
+ if (index !== -1) state.queue.splice(index, 1);
233
+ };
234
+ const onAbort = () => {
235
+ clearTimeout(timer);
236
+ removeEntry();
237
+ cleanup();
238
+ reject(Object.assign(new Error('Channel allocation aborted'), { name: 'AbortError' }));
239
+ };
194
240
  const timer = setTimeout(() => {
195
- const index = state.queue.findIndex(item => item.timer === timer);
196
- if (index !== -1) {
197
- state.queue.splice(index, 1);
198
- }
241
+ removeEntry();
242
+ cleanup();
199
243
  // 根据实际情况返回更准确的错误信息
200
244
  if (allFrozen) {
201
245
  reject(new Error('所有渠道均已被冻结,请等待健康检查恢复或手动重置'));
@@ -204,13 +248,19 @@ function allocateChannel(options = {}) {
204
248
  }
205
249
  }, WAIT_TIMEOUT_MS);
206
250
 
207
- state.queue.push({
251
+ entry = {
208
252
  source,
209
253
  options,
210
254
  resolve,
211
255
  reject,
212
- timer
213
- });
256
+ timer,
257
+ cleanup
258
+ };
259
+ state.queue.push(entry);
260
+ options.signal?.addEventListener('abort', onAbort, { once: true });
261
+ if (options.signal?.aborted) {
262
+ onAbort();
263
+ }
214
264
  });
215
265
  }
216
266
 
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+
6
+ function isPiNativeOAuthChannel(channel = {}) {
7
+ const transport = String(channel.transport || channel.providerConfig?.transport || '').trim();
8
+ return channel.authMode === 'oauth'
9
+ && transport === 'pi-native'
10
+ && Boolean(String(channel.apiKey || '').trim())
11
+ && Boolean(String(channel.baseUrl || '').trim());
12
+ }
13
+
14
+ function probeHealth(channel, timeoutMs) {
15
+ return new Promise((resolve) => {
16
+ let target;
17
+ try {
18
+ target = new URL('/healthz', channel.baseUrl);
19
+ } catch {
20
+ resolve(false);
21
+ return;
22
+ }
23
+ const transport = target.protocol === 'https:' ? https : http;
24
+ const request = transport.request(target, {
25
+ method: 'GET',
26
+ headers: {
27
+ accept: 'application/json'
28
+ }
29
+ }, (response) => {
30
+ response.resume();
31
+ response.once('end', () => {
32
+ resolve((response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300);
33
+ });
34
+ });
35
+ request.setTimeout(timeoutMs, () => {
36
+ request.destroy(new Error('OMP auth gateway health probe timed out'));
37
+ });
38
+ request.once('error', () => resolve(false));
39
+ request.end();
40
+ });
41
+ }
42
+
43
+ async function probeOmpAuthGateways(channels = [], options = {}) {
44
+ const candidates = channels.filter(isPiNativeOAuthChannel);
45
+ const timeoutMs = Math.max(100, Number(options.timeoutMs) || 1000);
46
+ const results = await Promise.all(candidates.map(async (channel) => ({
47
+ channel,
48
+ healthy: await probeHealth(channel, timeoutMs)
49
+ })));
50
+ return {
51
+ supportedOAuthChannelIds: results
52
+ .filter(result => result.healthy)
53
+ .map(result => result.channel.id),
54
+ warnings: results
55
+ .filter(result => !result.healthy)
56
+ .map(result => `OMP OAuth auth gateway for channel "${result.channel.name || result.channel.id}" is unavailable; the native OMP provider remains active.`)
57
+ };
58
+ }
59
+
60
+ module.exports = {
61
+ probeOmpAuthGateways
62
+ };
@@ -1,13 +1,12 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { execFileSync } = require('child_process');
4
- const { PATHS } = require('../../config/paths');
4
+ const { PATHS, ensureStorageDirMigrated } = require('../../config/paths');
5
5
  const BaseChannelService = require('./base/base-channel-service');
6
6
  const ompConfig = require('./omp-config');
7
7
  const {
8
8
  writeManagedOmpProviders,
9
9
  removeManagedOmpProviders,
10
- isManagedOmpProvidersActive,
11
10
  getLastManagedOmpSyncResult,
12
11
  readModelsConfig,
13
12
  readOmpSettingsConfig,
@@ -23,13 +22,82 @@ const {
23
22
 
24
23
  const OMP_THINKING_SUFFIX_RE = /:(minimal|low|medium|high|xhigh|off)$/;
25
24
 
25
+ function loadManagedOmpModeState() {
26
+ ensureStorageDirMigrated();
27
+ try {
28
+ if (!fs.existsSync(PATHS.activeChannel.omp)) return null;
29
+ const data = JSON.parse(fs.readFileSync(PATHS.activeChannel.omp, 'utf8'));
30
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function loadManagedOmpActiveChannelId() {
37
+ return loadManagedOmpModeState()?.activeChannelId || null;
38
+ }
39
+
40
+ function isManagedOmpModeEnabled() {
41
+ ensureStorageDirMigrated();
42
+ return fs.existsSync(PATHS.activeChannel.omp);
43
+ }
44
+
45
+ function writeManagedOmpModeState(filePath, state) {
46
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
47
+ try {
48
+ fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2), {
49
+ encoding: 'utf8',
50
+ mode: 0o600
51
+ });
52
+ fs.renameSync(temporaryPath, filePath);
53
+ } catch (error) {
54
+ if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ function enableManagedOmpMode(activeChannelId = null, gateway = null) {
60
+ ensureStorageDirMigrated();
61
+ const filePath = PATHS.activeChannel.omp;
62
+ const dir = path.dirname(filePath);
63
+ if (!fs.existsSync(dir)) {
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ }
66
+ const nextActiveChannelId = activeChannelId || loadManagedOmpActiveChannelId();
67
+ const previousState = loadManagedOmpModeState();
68
+ const nextGateway = gateway || previousState?.gateway || null;
69
+ const state = {
70
+ version: 2,
71
+ activeChannelId: nextActiveChannelId || null
72
+ };
73
+ if (nextGateway) {
74
+ state.gateway = {
75
+ host: String(nextGateway.host || '127.0.0.1'),
76
+ port: Number(nextGateway.port),
77
+ secret: String(nextGateway.secret || ''),
78
+ supportedOAuthChannelIds: Array.isArray(nextGateway.supportedOAuthChannelIds)
79
+ ? [...nextGateway.supportedOAuthChannelIds]
80
+ : []
81
+ };
82
+ }
83
+ writeManagedOmpModeState(filePath, state);
84
+ return nextActiveChannelId || null;
85
+ }
86
+
87
+ function disableManagedOmpMode() {
88
+ ensureStorageDirMigrated();
89
+ if (fs.existsSync(PATHS.activeChannel.omp)) {
90
+ fs.unlinkSync(PATHS.activeChannel.omp);
91
+ }
92
+ }
93
+
26
94
  class OmpChannelService extends BaseChannelService {
27
95
  constructor() {
28
96
  super({
29
97
  platform: 'omp',
30
98
  channelsFilePath: PATHS.channels.omp,
31
99
  defaultGatewaySource: 'openai_compatible',
32
- isProxyRunning: () => isManagedOmpProvidersActive()
100
+ isProxyRunning: () => isManagedOmpModeEnabled()
33
101
  });
34
102
  }
35
103
 
@@ -37,10 +105,16 @@ class OmpChannelService extends BaseChannelService {
37
105
  const normalized = super._applyDefaults(channel);
38
106
  normalized.providerKey = normalized.providerKey || normalized.provider || normalized.name || normalized.id;
39
107
  normalized.providerApi = normalized.providerApi || normalized.api || normalized.wireApi || 'openai-completions';
40
- normalized.authMode = normalized.authMode === 'oauth' ? 'oauth' : 'api_key';
108
+ normalized.authMode = ['api_key', 'oauth', 'none'].includes(normalized.authMode)
109
+ ? normalized.authMode
110
+ : 'api_key';
111
+ normalized.routingGroup = String(normalized.routingGroup || '').trim();
41
112
  normalized.oauthProviderId = normalized.oauthProviderId || (normalized.authMode === 'oauth' ? normalized.providerKey : '');
42
113
  if (normalized.authMode === 'oauth') {
43
114
  normalized.apiKey = normalized.apiKey || '';
115
+ } else if (normalized.authMode === 'none') {
116
+ normalized.apiKey = '';
117
+ normalized.oauthProviderId = '';
44
118
  }
45
119
  normalized.model = normalized.model || null;
46
120
  normalized.models = Array.isArray(normalized.models) ? normalized.models : [];
@@ -59,28 +133,46 @@ class OmpChannelService extends BaseChannelService {
59
133
  }
60
134
 
61
135
  _applyToNativeSettings(channel) {
62
- writeManagedOmpProviders([channel]);
136
+ if (isManagedOmpModeEnabled()) {
137
+ const state = loadManagedOmpModeState();
138
+ enableManagedOmpMode(channel.id, state?.gateway || null);
139
+ writeManagedOmpProviders([channel], state?.gateway ? {
140
+ gateway: state.gateway,
141
+ activeChannelId: channel.id
142
+ } : {});
143
+ }
63
144
  }
64
145
 
65
146
  _onAfterCreate(_channel, allChannels) {
66
- this.syncManagedOmpProviders(allChannels);
147
+ this.syncManagedOmpProvidersIfEnabled(allChannels);
67
148
  }
68
149
 
69
150
  _onAfterUpdate(_oldChannel, _newChannel, allChannels) {
70
- this.syncManagedOmpProviders(allChannels);
151
+ this.syncManagedOmpProvidersIfEnabled(allChannels);
71
152
  }
72
153
 
73
154
  _onAfterDelete(_channel, allChannels) {
74
- this.syncManagedOmpProviders(allChannels);
155
+ this.syncManagedOmpProvidersIfEnabled(allChannels);
156
+ }
157
+
158
+ syncManagedOmpProvidersIfEnabled(channels = this.getChannels().channels) {
159
+ if (!isManagedOmpModeEnabled()) {
160
+ return getLastManagedOmpSyncResult();
161
+ }
162
+ const state = loadManagedOmpModeState();
163
+ return this.syncManagedOmpProviders(channels, state?.gateway ? {
164
+ gateway: state.gateway,
165
+ activeChannelId: state.activeChannelId
166
+ } : {});
75
167
  }
76
168
 
77
- syncManagedOmpProviders(channels = this.getChannels().channels) {
169
+ syncManagedOmpProviders(channels = this.getChannels().channels, options = {}) {
78
170
  const enabledChannels = (channels || []).filter(channel => channel.enabled !== false);
79
171
  if (enabledChannels.length === 0) {
80
172
  removeManagedOmpProviders();
81
173
  return getLastManagedOmpSyncResult();
82
174
  }
83
- writeManagedOmpProviders(enabledChannels);
175
+ writeManagedOmpProviders(enabledChannels, options);
84
176
  return getLastManagedOmpSyncResult();
85
177
  }
86
178
 
@@ -90,7 +182,7 @@ class OmpChannelService extends BaseChannelService {
90
182
  }
91
183
 
92
184
  syncManagedProviderExtension(channels = this.getChannels().channels) {
93
- return this.syncManagedOmpProviders(channels);
185
+ return this.syncManagedOmpProvidersIfEnabled(channels);
94
186
  }
95
187
 
96
188
  disableManagedProviderExtension() {
@@ -396,9 +488,14 @@ module.exports = {
396
488
  applyChannelToSettings: (id) => service.applyChannelToSettings(id),
397
489
  disableAllChannels: () => service.disableAllChannels(),
398
490
  getEffectiveApiKey: (channel) => channel?.apiKey || null,
399
- syncManagedOmpProviders: (channels) => service.syncManagedOmpProviders(channels),
491
+ syncManagedOmpProviders: (channels, options) => service.syncManagedOmpProviders(channels, options),
400
492
  disableManagedOmpProviders: () => service.disableManagedOmpProviders(),
401
493
  syncManagedProviderExtension: (channels) => service.syncManagedProviderExtension(channels),
402
494
  disableManagedProviderExtension: () => service.disableManagedProviderExtension(),
495
+ isManagedOmpModeEnabled,
496
+ enableManagedOmpMode,
497
+ disableManagedOmpMode,
498
+ loadManagedOmpModeState,
499
+ loadManagedOmpActiveChannelId,
403
500
  syncCurrentOmpChannel
404
501
  };
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const MANAGED_PREFIX = 'ctx-';
6
+ const SUPPORTED_PROVIDER_APIS = new Set([
7
+ 'openai-completions',
8
+ 'openai-responses',
9
+ 'openai-codex-responses',
10
+ 'azure-openai-responses',
11
+ 'anthropic-messages',
12
+ 'google-generative-ai',
13
+ 'google-gemini-cli',
14
+ 'google-vertex'
15
+ ]);
16
+
17
+ function normalizeText(value) {
18
+ return String(value || '').trim();
19
+ }
20
+
21
+ function normalizeProviderId(value) {
22
+ return normalizeText(value)
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9._-]+/g, '-')
25
+ .replace(/^-+|-+$/g, '') || 'provider';
26
+ }
27
+
28
+ function getProviderKey(channel = {}) {
29
+ return normalizeText(channel.providerKey || channel.provider || channel.name || channel.id);
30
+ }
31
+
32
+ function getProviderApi(channel = {}) {
33
+ return normalizeText(channel.providerApi || channel.api || channel.wireApi || 'openai-completions');
34
+ }
35
+
36
+ function getRoutingGroup(channel = {}) {
37
+ return normalizeText(channel.routingGroup) || normalizeText(channel.id);
38
+ }
39
+
40
+ function stableToken(secret, namespace, value, length = 24) {
41
+ return crypto
42
+ .createHmac('sha256', String(secret || ''))
43
+ .update(`${namespace}\0${value}`)
44
+ .digest('hex')
45
+ .slice(0, length);
46
+ }
47
+
48
+ function stablePublicId(namespace, value, length = 8) {
49
+ return crypto
50
+ .createHash('sha256')
51
+ .update(`${namespace}\0${value}`)
52
+ .digest('hex')
53
+ .slice(0, length);
54
+ }
55
+
56
+ function normalizeRedirects(channel = {}) {
57
+ const redirects = Array.isArray(channel.modelRedirects) ? channel.modelRedirects : [];
58
+ return JSON.stringify(redirects);
59
+ }
60
+
61
+ function canonicalize(value) {
62
+ if (Array.isArray(value)) return value.map(canonicalize);
63
+ if (value && typeof value === 'object') {
64
+ return Object.fromEntries(
65
+ Object.keys(value).sort().map(key => [key, canonicalize(value[key])])
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function getModelCapabilityMap(channel = {}) {
72
+ const result = new Map();
73
+ const add = (raw) => {
74
+ const model = typeof raw === 'string' ? { id: raw } : raw;
75
+ const id = normalizeText(model?.id || model?.name);
76
+ if (!id || result.has(id)) return;
77
+ const capability = { ...(model || {}) };
78
+ delete capability.id;
79
+ delete capability.name;
80
+ delete capability.baseUrl;
81
+ delete capability.headers;
82
+ result.set(id, JSON.stringify(canonicalize(capability)));
83
+ };
84
+ (Array.isArray(channel.models) ? channel.models : []).forEach(add);
85
+ (Array.isArray(channel.allowedModels) ? channel.allowedModels : []).forEach(add);
86
+ add(channel.model);
87
+ return result;
88
+ }
89
+
90
+ function getGroupKey(channel = {}) {
91
+ return [
92
+ getProviderKey(channel),
93
+ getProviderApi(channel),
94
+ getRoutingGroup(channel)
95
+ ].join('\0');
96
+ }
97
+
98
+ function assertCompatibleGroup(channels = []) {
99
+ if (channels.length <= 1) return;
100
+ const first = channels[0];
101
+ const expectedApi = getProviderApi(first);
102
+ const expectedProvider = getProviderKey(first);
103
+ const expectedRedirects = normalizeRedirects(first);
104
+ const expectedAuthMode = normalizeText(first.authMode) || 'api_key';
105
+ const expectedCapabilities = getModelCapabilityMap(first);
106
+
107
+ channels.slice(1).forEach((channel) => {
108
+ if (getProviderKey(channel) !== expectedProvider) {
109
+ throw new Error(`OMP routing group "${getRoutingGroup(first)}" mixes providerKey values`);
110
+ }
111
+ if (getProviderApi(channel) !== expectedApi) {
112
+ throw new Error(`OMP routing group "${getRoutingGroup(first)}" mixes providerApi values`);
113
+ }
114
+ if (normalizeRedirects(channel) !== expectedRedirects) {
115
+ throw new Error(`OMP routing group "${getRoutingGroup(first)}" has inconsistent modelRedirects`);
116
+ }
117
+ if ((normalizeText(channel.authMode) || 'api_key') !== expectedAuthMode) {
118
+ throw new Error(`OMP routing group "${getRoutingGroup(first)}" mixes authMode values`);
119
+ }
120
+ const actualCapabilities = getModelCapabilityMap(channel);
121
+ expectedCapabilities.forEach((capability, modelId) => {
122
+ if (actualCapabilities.has(modelId) && actualCapabilities.get(modelId) !== capability) {
123
+ throw new Error(
124
+ `OMP routing group "${getRoutingGroup(first)}" has incompatible capability metadata for model "${modelId}"`
125
+ );
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ function sanitizeProviderConfig(channel = {}) {
132
+ const source = channel.providerConfig && typeof channel.providerConfig === 'object'
133
+ && !Array.isArray(channel.providerConfig)
134
+ ? channel.providerConfig
135
+ : {};
136
+ const next = { ...source };
137
+ for (const key of ['baseUrl', 'base_url', 'apiKey', 'api_key', 'headers', 'auth']) {
138
+ delete next[key];
139
+ }
140
+ return next;
141
+ }
142
+
143
+ function sanitizeModels(channel = {}) {
144
+ return (Array.isArray(channel.models) ? channel.models : []).map((model) => {
145
+ if (!model || typeof model !== 'object' || Array.isArray(model)) return model;
146
+ const next = { ...model };
147
+ delete next.baseUrl;
148
+ delete next.headers;
149
+ return next;
150
+ });
151
+ }
152
+
153
+ function mergeGroupField(channels = [], field) {
154
+ const seen = new Set();
155
+ const result = [];
156
+ channels.forEach((channel) => {
157
+ const values = Array.isArray(channel?.[field]) ? channel[field] : [];
158
+ values.forEach((value) => {
159
+ const id = normalizeText(typeof value === 'string' ? value : (value?.id || value?.name));
160
+ if (!id || seen.has(id.toLowerCase())) return;
161
+ seen.add(id.toLowerCase());
162
+ result.push(value);
163
+ });
164
+ });
165
+ return result;
166
+ }
167
+
168
+ function buildManagedBaseUrl(host, port, routeToken) {
169
+ return `http://${host}:${port}/omp/${routeToken}`;
170
+ }
171
+
172
+ function prepareManagedOmpChannels(channels = [], gateway = {}) {
173
+ const host = normalizeText(gateway.host) || '127.0.0.1';
174
+ const port = Number(gateway.port);
175
+ const secret = normalizeText(gateway.secret);
176
+ if (!Number.isInteger(port) || port <= 0) {
177
+ throw new Error('OMP gateway port is required before managed providers can be generated');
178
+ }
179
+ if (!secret) {
180
+ throw new Error('OMP gateway secret is required before managed providers can be generated');
181
+ }
182
+
183
+ const enabled = channels.filter(channel => channel && channel.enabled !== false && channel.baseUrl);
184
+ const groups = new Map();
185
+ enabled.forEach((channel) => {
186
+ const key = getGroupKey(channel);
187
+ if (!groups.has(key)) groups.set(key, []);
188
+ groups.get(key).push(channel);
189
+ });
190
+
191
+ const providerCounts = new Map();
192
+ groups.forEach((groupChannels) => {
193
+ const providerId = normalizeProviderId(getProviderKey(groupChannels[0]));
194
+ providerCounts.set(providerId, (providerCounts.get(providerId) || 0) + 1);
195
+ });
196
+
197
+ const routes = [];
198
+ const managedChannels = [];
199
+ const unsupportedChannels = [];
200
+
201
+ groups.forEach((groupChannels, groupKey) => {
202
+ assertCompatibleGroup(groupChannels);
203
+ const template = groupChannels[0];
204
+ if (!SUPPORTED_PROVIDER_APIS.has(getProviderApi(template))) {
205
+ groupChannels.forEach(channel => unsupportedChannels.push({
206
+ id: channel.id,
207
+ name: channel.name,
208
+ reason: 'unsupported-provider-api'
209
+ }));
210
+ return;
211
+ }
212
+ const authMode = normalizeText(template.authMode) || 'api_key';
213
+ const transport = normalizeText(template.transport || template.providerConfig?.transport);
214
+ const supportedOAuthIds = Array.isArray(gateway.supportedOAuthChannelIds)
215
+ ? new Set(gateway.supportedOAuthChannelIds)
216
+ : null;
217
+ const oauthGatewayUnavailable = authMode === 'oauth' && (
218
+ !normalizeText(template.apiKey)
219
+ || transport !== 'pi-native'
220
+ || (supportedOAuthIds && groupChannels.some(channel => !supportedOAuthIds.has(channel.id)))
221
+ );
222
+ if (oauthGatewayUnavailable) {
223
+ groupChannels.forEach(channel => unsupportedChannels.push({
224
+ id: channel.id,
225
+ name: channel.name,
226
+ reason: 'oauth-auth-gateway-unavailable'
227
+ }));
228
+ return;
229
+ }
230
+
231
+ const providerId = normalizeProviderId(getProviderKey(template));
232
+ const routeToken = stableToken(secret, 'route', groupKey);
233
+ const capability = `ctx_${stableToken(secret, 'capability', groupKey, 40)}`;
234
+ const multipleGroups = providerCounts.get(providerId) > 1;
235
+ const managedProviderId = multipleGroups
236
+ ? `${MANAGED_PREFIX}${providerId}-${stablePublicId('provider', groupKey)}`
237
+ : `${MANAGED_PREFIX}${providerId}`;
238
+ const routingGroup = getRoutingGroup(template);
239
+ const mergedModels = sanitizeModels({
240
+ models: mergeGroupField(groupChannels, 'models')
241
+ });
242
+ const mergedAllowedModels = mergeGroupField(groupChannels, 'allowedModels');
243
+
244
+ const route = {
245
+ token: routeToken,
246
+ capability,
247
+ routingGroup,
248
+ providerKey: getProviderKey(template),
249
+ providerApi: getProviderApi(template),
250
+ managedProviderId,
251
+ channelIds: groupChannels.map(channel => channel.id),
252
+ authMode
253
+ };
254
+ routes.push(route);
255
+
256
+ managedChannels.push({
257
+ ...template,
258
+ id: `omp-route-${routeToken}`,
259
+ managedProviderId,
260
+ originalProviderId: getProviderKey(template),
261
+ routingGroup,
262
+ baseUrl: buildManagedBaseUrl(host, port, routeToken),
263
+ apiKey: authMode === 'none' ? '' : capability,
264
+ authMode,
265
+ providerConfig: sanitizeProviderConfig(template),
266
+ headers: undefined,
267
+ auth: authMode === 'none' ? 'none' : undefined,
268
+ models: mergedModels,
269
+ allowedModels: mergedAllowedModels,
270
+ _ompGatewayRoute: route
271
+ });
272
+ });
273
+
274
+ return { routes, managedChannels, unsupportedChannels };
275
+ }
276
+
277
+ function resolveOmpGatewayRoute(pathname, channels, gateway) {
278
+ const match = String(pathname || '').match(/^\/omp\/([a-f0-9]{24})(\/.*|$)/);
279
+ if (!match) return null;
280
+ const prepared = prepareManagedOmpChannels(channels, gateway);
281
+ const route = prepared.routes.find(item => item.token === match[1]);
282
+ if (!route) return null;
283
+ return {
284
+ ...route,
285
+ upstreamPath: match[2] || '/'
286
+ };
287
+ }
288
+
289
+ module.exports = {
290
+ prepareManagedOmpChannels,
291
+ resolveOmpGatewayRoute,
292
+ getRoutingGroup,
293
+ getProviderKey,
294
+ getProviderApi
295
+ };