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.
- package/dist/web/assets/{Analytics-D14GGF2y.js → Analytics-Ck_00Brj.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-C0TeUSZg.js → ConfigTemplates-Cpw2vUa8.js} +1 -1
- package/dist/web/assets/Home-Bft3Kztl.js +1 -0
- package/dist/web/assets/Home-CHZJkkjH.css +1 -0
- package/dist/web/assets/{PluginManager-DPZvPrT4.js → PluginManager-2RlvEJCT.js} +1 -1
- package/dist/web/assets/{ProjectList-DUaiCvsF.js → ProjectList-_ecCngpl.js} +1 -1
- package/dist/web/assets/{SessionList-XdHNM61o.js → SessionList-DlWcw3hO.js} +1 -1
- package/dist/web/assets/{SkillManager-I4LFlsVN.js → SkillManager-CnY7IXkX.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-zMI7Lhzf.js → WorkspaceManager-3mqghi09.js} +1 -1
- package/dist/web/assets/{index-C5nLTbOk.css → index-BceXgSSe.css} +1 -1
- package/dist/web/assets/index-k5oP7c-A.js +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/commands/toggle-proxy.js +7 -8
- package/src/commands/ui.js +11 -1
- package/src/config/default.js +1 -1
- package/src/index.js +41 -15
- package/src/server/api/omp-channels.js +16 -5
- package/src/server/api/omp-config.js +1 -1
- package/src/server/api/omp-proxy.js +13 -42
- package/src/server/index.js +3 -3
- package/src/server/omp-proxy-server.js +129 -10
- package/src/server/services/channel-scheduler.js +59 -9
- package/src/server/services/omp-auth-gateway-client.js +62 -0
- package/src/server/services/omp-channels.js +109 -12
- package/src/server/services/omp-gateway-routing.js +295 -0
- package/src/server/services/omp-gateway.js +919 -0
- package/src/server/services/omp-session-log-observer.js +138 -0
- package/src/server/services/omp-sessions.js +80 -0
- package/src/server/services/omp-settings-manager.js +358 -89
- package/dist/web/assets/Home-CUh_BsGm.js +0 -1
- package/dist/web/assets/Home-CqnBXFxj.css +0 -1
- 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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
147
|
+
this.syncManagedOmpProvidersIfEnabled(allChannels);
|
|
67
148
|
}
|
|
68
149
|
|
|
69
150
|
_onAfterUpdate(_oldChannel, _newChannel, allChannels) {
|
|
70
|
-
this.
|
|
151
|
+
this.syncManagedOmpProvidersIfEnabled(allChannels);
|
|
71
152
|
}
|
|
72
153
|
|
|
73
154
|
_onAfterDelete(_channel, allChannels) {
|
|
74
|
-
this.
|
|
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.
|
|
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
|
+
};
|